diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index a1945826571ce..770091010334c 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -422,6 +422,7 @@ add_action( 'do_all_pings', 'generic_ping', 10, 0 ); add_action( 'do_robots', 'do_robots' ); add_action( 'do_favicon', 'do_favicon' ); +add_action( 'wp_before_include_template', 'wp_start_template_enhancement_output_buffer' ); add_action( 'set_comment_cookies', 'wp_set_comment_cookies', 10, 3 ); add_action( 'sanitize_comment_cookies', 'sanitize_comment_cookies' ); add_action( 'init', 'smilies_init', 5 ); diff --git a/src/wp-includes/template-loader.php b/src/wp-includes/template-loader.php index ab8700d5d3759..ffd2567524622 100644 --- a/src/wp-includes/template-loader.php +++ b/src/wp-includes/template-loader.php @@ -113,6 +113,15 @@ */ $template = apply_filters( 'template_include', $template ); if ( $template ) { + /** + * Fires immediately before including the template. + * + * @since 6.9.0 + * + * @param string $template The path of the template about to be included. + */ + do_action( 'wp_before_include_template', $template ); + include $template; } elseif ( current_user_can( 'switch_themes' ) ) { $theme = wp_get_theme(); diff --git a/src/wp-includes/template.php b/src/wp-includes/template.php index 401df4db19925..65771bf418e42 100644 --- a/src/wp-includes/template.php +++ b/src/wp-includes/template.php @@ -823,3 +823,144 @@ function load_template( $_template_file, $load_once = true, $args = array() ) { */ do_action( 'wp_after_load_template', $_template_file, $load_once, $args ); } + +/** + * Checks whether the template should be output buffered for enhancement. + * + * By default, an output buffer is only started if a {@see 'wp_template_enhancement_output_buffer'} filter has been + * added be the time a template is included at the {@see 'wp_before_include_template'} action. This allows template + * responses to be streamed as much as possible when no template enhancements are registered to apply. + * + * @since 6.9.0 + * + * @return bool Whether the template should be output-buffered for enhancement. + */ +function wp_should_output_buffer_template_for_enhancement(): bool { + /** + * Filters whether the template should be output-buffered for enhancement. + * + * By default, an output buffer is only started if a {@see 'wp_template_enhancement_output_buffer'} filter has been + * added. For this default to apply, a filter must be added by the time the template is included at the + * {@see 'wp_before_include_template'} action. This allows template responses to be streamed as much as possible + * when no template enhancements are registered to apply. This filter allows a site to opt in to adding such + * template enhancement filters during the rendering of the template. + * + * @since 6.9.0 + * + * @param bool $use_output_buffer Whether an output buffer is started. + */ + return (bool) apply_filters( 'wp_should_output_buffer_template_for_enhancement', has_filter( 'wp_template_enhancement_output_buffer' ) ); +} + +/** + * Starts the template enhancement output buffer. + * + * This function is called immediately before the template is included. + * + * @since 6.9.0 + * + * @return bool Whether the output buffer successfully started. + */ +function wp_start_template_enhancement_output_buffer(): bool { + if ( ! wp_should_output_buffer_template_for_enhancement() ) { + return false; + } + + $started = ob_start( + 'wp_finalize_template_enhancement_output_buffer', + 0, // Unlimited buffer size so that entire output is passed to the filter. + /* + * Instead of the default PHP_OUTPUT_HANDLER_STDFLAGS (cleanable, flushable, and removable) being used for + * flags, the PHP_OUTPUT_HANDLER_FLUSHABLE flag must be omitted. If the buffer were flushable, then each time + * that ob_flush() is called, a fragment of the output would be sent into the output buffer callback. This + * output buffer is intended to capture the entire response for processing, as indicated by the chunk size of 0. + * So the buffer does not allow flushing to ensure the entire buffer can be processed, such as for optimizing an + * entire HTML document, where markup in the HEAD may need to be adjusted based on markup that appears late in + * the BODY. + * + * If this ends up being problematic, then PHP_OUTPUT_HANDLER_FLUSHABLE could be added to the $flags and the + * output buffer callback could check if the phase is PHP_OUTPUT_HANDLER_FLUSH and abort any subsequent + * processing while also emitting a _doing_it_wrong(). + * + * The output buffer needs to be removable because WordPress calls wp_ob_end_flush_all() and then calls + * wp_cache_close(). If the buffers are not all flushed before wp_cache_close() is closed, then some output buffer + * handlers (e.g. for caching plugins) may fail to be able to store the page output in the object cache. + * See . + */ + PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE + ); + + if ( $started ) { + /** + * Fires when the template enhancement output buffer has started. + * + * @since 6.9.0 + */ + do_action( 'wp_template_enhancement_output_buffer_started' ); + } + + return $started; +} + +/** + * Finalizes the template enhancement output buffer. + * + * Checks to see if the output buffer is complete and contains HTML. If so, runs the content through + * the `wp_template_enhancement_output_buffer` filter. If not, the original content is returned. + * + * @since 6.9.0 + * + * @see wp_start_template_enhancement_output_buffer() + * + * @param string $output Output buffer. + * @param int $phase Phase. + * @return string Finalized output buffer. + */ +function wp_finalize_template_enhancement_output_buffer( string $output, int $phase ): string { + // When the output is being cleaned (e.g. pending template is replaced with error page), do not send it through the filter. + if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) { + return $output; + } + + // Detect if the response is an HTML content type. + $is_html_content_type = null; + $html_content_types = array( 'text/html', 'application/xhtml+xml' ); + foreach ( headers_list() as $header ) { + $header_parts = preg_split( '/\s*[:;]\s*/', strtolower( $header ) ); + if ( + is_array( $header_parts ) && + count( $header_parts ) >= 2 && + 'content-type' === $header_parts[0] + ) { + $is_html_content_type = in_array( $header_parts[1], $html_content_types, true ); + break; // PHP only sends the first Content-Type header in the list. + } + } + if ( null === $is_html_content_type ) { + $is_html_content_type = in_array( ini_get( 'default_mimetype' ), $html_content_types, true ); + } + + // If the content type is not HTML, short-circuit since it is not relevant for enhancement. + if ( ! $is_html_content_type ) { + return $output; + } + + $filtered_output = $output; + + /** + * Filters the template enhancement output buffer prior to sending to the client. + * + * This filter only applies the HTML output of an included template. This filter is a progressive enhancement + * intended for applications such as optimizing markup to improve frontend page load performance. Sites must not + * depend on this filter applying since they may opt to stream the responses instead. Callbacks for this filter are + * highly discouraged from using regular expressions to do any kind of replacement on the output. Use the HTML API + * (either `WP_HTML_Tag_Processor` or `WP_HTML_Processor`), or else use {@see DOM\HtmlDocument} as of PHP 8.4 which + * fully supports HTML5. + * + * @since 6.9.0 + * + * @param string $filtered_output HTML template enhancement output buffer. + * @param string $output Original HTML template output buffer. + */ + return (string) apply_filters( 'wp_template_enhancement_output_buffer', $filtered_output, $output ); +} diff --git a/tests/phpunit/tests/template.php b/tests/phpunit/tests/template.php index 0507be4327250..676c08129b526 100644 --- a/tests/phpunit/tests/template.php +++ b/tests/phpunit/tests/template.php @@ -63,8 +63,14 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { ); } + /** + * @var string + */ + protected $original_default_mimetype; + public function set_up() { parent::set_up(); + $this->original_default_mimetype = ini_get( 'default_mimetype' ); register_post_type( 'cpt', array( @@ -83,6 +89,7 @@ public function set_up() { } public function tear_down() { + ini_set( 'default_mimetype', $this->original_default_mimetype ); unregister_post_type( 'cpt' ); unregister_taxonomy( 'taxo' ); $this->set_permalink_structure( '' ); @@ -494,6 +501,333 @@ public function test_locate_template_uses_current_theme() { $this->assertSame( $new_theme->get_stylesheet_directory() . '/index.php', locate_template( $template_names ), 'Incorrect index template found in theme after switch.' ); } + /** + * Tests that wp_start_template_enhancement_output_buffer() does not start a buffer when no filters are present. + * + * @ticket 43258 + * @covers ::wp_should_output_buffer_template_for_enhancement + * @covers ::wp_start_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_without_filters_and_no_override(): void { + remove_all_filters( 'wp_template_enhancement_output_buffer' ); + $level = ob_get_level(); + $this->assertFalse( wp_should_output_buffer_template_for_enhancement(), 'Expected wp_should_output_buffer_template_for_enhancement() to return false when there are no wp_template_enhancement_output_buffer filters added.' ); + $this->assertFalse( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return false because the output buffer should not be started.' ); + $this->assertSame( 0, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to not have fired.' ); + $this->assertSame( $level, ob_get_level(), 'Expected the initial output buffer level to be unchanged.' ); + } + + /** + * Tests that wp_start_template_enhancement_output_buffer() does start a buffer when no filters are present but there is an override. + * + * @ticket 43258 + * @covers ::wp_should_output_buffer_template_for_enhancement + * @covers ::wp_start_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_begins_without_filters_but_overridden(): void { + remove_all_filters( 'wp_template_enhancement_output_buffer' ); + $level = ob_get_level(); + add_filter( 'wp_should_output_buffer_template_for_enhancement', '__return_true' ); + $this->assertTrue( wp_should_output_buffer_template_for_enhancement(), 'Expected wp_should_output_buffer_template_for_enhancement() to return true when overridden with the wp_should_output_buffer_template_for_enhancement filter.' ); + $this->assertTrue( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return true because the output buffer should be started due to the override.' ); + $this->assertSame( 1, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to have fired.' ); + $this->assertSame( $level + 1, ob_get_level(), 'Expected the output buffer level to have been incremented.' ); + ob_end_clean(); + } + + /** + * Tests that wp_start_template_enhancement_output_buffer() does not start a buffer even when there are filters present due to override. + * + * @ticket 43258 + * @covers ::wp_should_output_buffer_template_for_enhancement + * @covers ::wp_start_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_begins_with_filters_but_blocked(): void { + add_filter( + 'wp_template_enhancement_output_buffer', + static function () { + return 'Hey!'; + } + ); + $level = ob_get_level(); + add_filter( 'wp_should_output_buffer_template_for_enhancement', '__return_false' ); + $this->assertFalse( wp_should_output_buffer_template_for_enhancement(), 'Expected wp_should_output_buffer_template_for_enhancement() to return false since wp_should_output_buffer_template_for_enhancement was filtered to be false even though there is a wp_template_enhancement_output_buffer filter added.' ); + $this->assertFalse( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return false because the output buffer should not be started.' ); + $this->assertSame( 0, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to not have fired.' ); + $this->assertSame( $level, ob_get_level(), 'Expected the initial output buffer level to be unchanged.' ); + } + + /** + * Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer and that the expected hooks fire for + * an HTML document and that the response is not incrementally flushable. + * + * @ticket 43258 + * @covers ::wp_start_template_enhancement_output_buffer + * @covers ::wp_finalize_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_for_html(): void { + // Start a wrapper output buffer so that we can flush the inner buffer. + ob_start(); + + $filter_args = null; + add_filter( + 'wp_template_enhancement_output_buffer', + static function ( string $buffer ) use ( &$filter_args ): string { + $filter_args = func_get_args(); + + $p = WP_HTML_Processor::create_full_parser( $buffer ); + while ( $p->next_tag() ) { + echo $p->get_tag() . PHP_EOL; + switch ( $p->get_tag() ) { + case 'HTML': + $p->set_attribute( 'lang', 'es' ); + break; + case 'TITLE': + $p->set_modifiable_text( 'Saludo' ); + break; + case 'H1': + if ( $p->next_token() && '#text' === $p->get_token_name() ) { + $p->set_modifiable_text( '¡Hola, mundo!' ); + } + break; + } + } + return $p->get_updated_html(); + }, + 10, + PHP_INT_MAX + ); + + $initial_ob_level = ob_get_level(); + $this->assertTrue( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return true indicating the output buffer started.' ); + $this->assertSame( 1, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to have fired.' ); + $this->assertSame( $initial_ob_level + 1, ob_get_level(), 'Expected the output buffer level to have been incremented' ); + + ?> + + + + Greeting + + assertFalse( + @ob_flush(), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + 'Expected output buffer to not be incrementally flushable.' + ); + ?> + +

Hello World!

+ + + assertSame( 'wp_finalize_template_enhancement_output_buffer', $ob_status['name'], 'Expected name to be WP function.' ); + $this->assertSame( 1, $ob_status['type'], 'Expected type to be user supplied handler.' ); + $this->assertSame( 0, $ob_status['chunk_size'], 'Expected unlimited chunk size.' ); + + ob_end_flush(); // End the buffer started by wp_start_template_enhancement_output_buffer(). + $this->assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' ); + + $this->assertIsArray( $filter_args, 'Expected the wp_template_enhancement_output_buffer filter to have applied.' ); + $this->assertCount( 2, $filter_args, 'Expected two args to be supplied to the wp_template_enhancement_output_buffer filter.' ); + $this->assertIsString( $filter_args[0], 'Expected the $filtered_output param to the wp_template_enhancement_output_buffer filter to be a string.' ); + $this->assertIsString( $filter_args[1], 'Expected the $output param to the wp_template_enhancement_output_buffer filter to be a string.' ); + $this->assertSame( $filter_args[1], $filter_args[0], 'Expected the initial $filtered_output to match $output in the wp_template_enhancement_output_buffer filter.' ); + $original_output = $filter_args[0]; + $this->assertStringContainsString( '', $original_output, 'Expected original output to contain string.' ); + $this->assertStringContainsString( '', $original_output, 'Expected original output to contain string.' ); + $this->assertStringContainsString( 'Greeting', $original_output, 'Expected original output to contain string.' ); + $this->assertStringContainsString( '

Hello World!

', $original_output, 'Expected original output to contain string.' ); + $this->assertStringContainsString( '', $original_output, 'Expected original output to contain string.' ); + + $processed_output = ob_get_clean(); // Obtain the output via the wrapper output buffer. + $this->assertIsString( $processed_output ); + $this->assertNotEquals( $original_output, $processed_output ); + + $this->assertStringContainsString( '', $processed_output, 'Expected processed output to contain string.' ); + $this->assertStringContainsString( '', $processed_output, 'Expected processed output to contain string.' ); + $this->assertStringContainsString( 'Saludo', $processed_output, 'Expected processed output to contain string.' ); + $this->assertStringContainsString( '

¡Hola, mundo!

', $processed_output, 'Expected processed output to contain string.' ); + $this->assertStringContainsString( '', $processed_output, 'Expected processed output to contain string.' ); + } + + /** + * Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer but ending with cleaning prevents any processing. + * + * @ticket 43258 + * @covers ::wp_start_template_enhancement_output_buffer + * @covers ::wp_finalize_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_ended_cleaned(): void { + // Start a wrapper output buffer so that we can flush the inner buffer. + ob_start(); + + $applied_filter = false; + add_filter( + 'wp_template_enhancement_output_buffer', + static function ( string $buffer ) use ( &$applied_filter ): string { + $applied_filter = true; + + $p = WP_HTML_Processor::create_full_parser( $buffer ); + if ( $p->next_tag( array( 'tag_name' => 'TITLE' ) ) ) { + $p->set_modifiable_text( 'Processed' ); + } + return $p->get_updated_html(); + } + ); + + $initial_ob_level = ob_get_level(); + $this->assertTrue( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return true indicating the output buffer started.' ); + $this->assertSame( 1, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to have fired.' ); + $this->assertSame( $initial_ob_level + 1, ob_get_level(), 'Expected the output buffer level to have been incremented' ); + + ?> + + + + Unprocessed + + +

Hello World!

+ + + + + + Output Buffer Not Processed + + +

Template rendering aborted!!!

+ + + assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' ); + + $this->assertFalse( $applied_filter, 'Expected the wp_template_enhancement_output_buffer filter to not have applied.' ); + $this->assertSame( 0, did_action( 'wp_final_template_output_buffer' ), 'Expected the wp_final_template_output_buffer action to not have fired.' ); + + // Obtain the output via the wrapper output buffer. + $output = ob_get_clean(); + $this->assertIsString( $output, 'Expected ob_get_clean() to return a string.' ); + $this->assertStringNotContainsString( 'Unprocessed', $output, 'Expected output buffer to not have string since the template was overridden.' ); + $this->assertStringNotContainsString( 'Processed', $output, 'Expected output buffer to not have string since the filter did not apply.' ); + $this->assertStringContainsString( 'Output Buffer Not Processed', $output, 'Expected output buffer to have string since the output buffer was ended with cleaning.' ); + } + + /** + * Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer and cleaning allows the template to be replaced. + * + * @ticket 43258 + * @covers ::wp_start_template_enhancement_output_buffer + * @covers ::wp_finalize_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_cleaned_and_replaced(): void { + // Start a wrapper output buffer so that we can flush the inner buffer. + ob_start(); + + $called_filter = false; + add_filter( + 'wp_template_enhancement_output_buffer', + static function ( string $buffer ) use ( &$called_filter ): string { + $called_filter = true; + + $p = WP_HTML_Processor::create_full_parser( $buffer ); + if ( $p->next_tag( array( 'tag_name' => 'TITLE' ) ) ) { + $p->set_modifiable_text( 'Processed' ); + } + return $p->get_updated_html(); + } + ); + + $initial_ob_level = ob_get_level(); + $this->assertTrue( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return true indicating the output buffer started.' ); + $this->assertSame( 1, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to have fired.' ); + $this->assertSame( $initial_ob_level + 1, ob_get_level(), 'Expected the output buffer level to have been incremented.' ); + + ?> + + + + Unprocessed + + +

Hello World!

+ + + + + + Template Replaced + + +

Template Replaced

+

The original template called ob_clean() which allowed this template to take its place.

+ + + assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' ); + + $this->assertTrue( $called_filter, 'Expected the wp_template_enhancement_output_buffer filter to have applied.' ); + + // Obtain the output via the wrapper output buffer. + $output = ob_get_clean(); + $this->assertIsString( $output, 'Expected ob_get_clean() to return a string.' ); + $this->assertStringNotContainsString( 'Unprocessed', $output, 'Expected output buffer to not have string due to template override.' ); + $this->assertStringContainsString( 'Processed', $output, 'Expected output buffer to have string due to filtering.' ); + $this->assertStringContainsString( '

Template Replaced

', $output, 'Expected output buffer to have string due to replaced template.' ); + } + + /** + * Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer and that the output buffer is not processed. + * + * @ticket 43258 + * @covers ::wp_start_template_enhancement_output_buffer + * @covers ::wp_finalize_template_enhancement_output_buffer + */ + public function test_wp_start_template_enhancement_output_buffer_for_json(): void { + // Start a wrapper output buffer so that we can flush the inner buffer. + ob_start(); + + $mock_filter_callback = new MockAction(); + add_filter( 'wp_template_enhancement_output_buffer', array( $mock_filter_callback, 'filter' ) ); + + $initial_ob_level = ob_get_level(); + $this->assertTrue( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return true indicating the output buffer started.' ); + $this->assertSame( 1, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to have fired.' ); + $this->assertSame( $initial_ob_level + 1, ob_get_level(), 'Expected the output buffer level to have been incremented.' ); + + ini_set( 'default_mimetype', 'application/json' ); // Since sending a header won't work. + $json = wp_json_encode( + array( + 'success' => true, + 'data' => array( + 'message' => 'Hello, world!', + 'fish' => '<', // Something that looks like HTML. + ), + ) + ); + echo $json; + + $ob_status = ob_get_status(); + $this->assertSame( 'wp_finalize_template_enhancement_output_buffer', $ob_status['name'], 'Expected name to be WP function.' ); + $this->assertSame( 1, $ob_status['type'], 'Expected type to be user supplied handler.' ); + $this->assertSame( 0, $ob_status['chunk_size'], 'Expected unlimited chunk size.' ); + + ob_end_flush(); // End the buffer started by wp_start_template_enhancement_output_buffer(). + $this->assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' ); + + $this->assertSame( 0, $mock_filter_callback->get_call_count(), 'Expected the wp_template_enhancement_output_buffer filter to not have applied.' ); + + // Obtain the output via the wrapper output buffer. + $output = ob_get_clean(); + $this->assertIsString( $output, 'Expected ob_get_clean() to return a string.' ); + $this->assertSame( $json, $output, 'Expected output to not be processed.' ); + } + public function assertTemplateHierarchy( $url, array $expected, $message = '' ) { $this->go_to( $url ); $hierarchy = $this->get_template_hierarchy();