PluginProbe
Optimization Detective / trunk
Optimization Detective vtrunk
1.0.0-beta7 trunk 0.1.0 0.1.1 0.2.0 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0 0.9.0 1.0.0-beta1 1.0.0-beta2 1.0.0-beta3 1.0.0-beta4 1.0.0-beta5 1.0.0-beta6
optimization-detective / optimization.php

optimization.php in Optimization Detective trunk, at optimization.php

369 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optimizing for Optimization Detective.
4 *
5 * @package optimization-detective
6 * @since 0.1.0
7 */
8
9 declare( strict_types = 1 );
10
11 // @codeCoverageIgnoreStart
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit; // Exit if accessed directly.
14 }
15 // @codeCoverageIgnoreEnd
16
17 /**
18 * Starts output buffering at the end of the 'template_include' filter.
19 *
20 * This is to implement #43258 in core.
21 *
22 * This is a hack that would eventually be replaced with something like this in wp-includes/template-loader.php:
23 *
24 * $template = apply_filters( 'template_include', $template );
25 * + ob_start( 'wp_template_output_buffer_callback' );
26 * if ( $template ) {
27 * include $template;
28 * } elseif ( current_user_can( 'switch_themes' ) ) {
29 *
30 * @since 0.1.0
31 * @access private
32 * @link https://core.trac.wordpress.org/ticket/43258
33 *
34 * @param string|mixed $passthrough Value for the template_include filter which is passed through.
35 * @return string|mixed Unmodified value of $passthrough.
36 */
37 function od_buffer_output( $passthrough ) {
38 /*
39 * Instead of the default PHP_OUTPUT_HANDLER_STDFLAGS (cleanable, flushable, and removable) being used for flags,
40 * we need to omit PHP_OUTPUT_HANDLER_FLUSHABLE. If the buffer were flushable, then each time that ob_flush() is
41 * called, it would send a fragment of the output into the output buffer callback. When buffering the entire
42 * response as an HTML document, this would result in broken HTML processing.
43 *
44 * If this ends up being problematic, then PHP_OUTPUT_HANDLER_FLUSHABLE could be added to the $flags and the
45 * output buffer callback could check if the phase is PHP_OUTPUT_HANDLER_FLUSH and abort any later
46 * processing while also emitting a _doing_it_wrong().
47 *
48 * The output buffer needs to be removable because WordPress calls wp_ob_end_flush_all() and then calls
49 * wp_cache_close(). If the buffers are not all flushed before wp_cache_close() is closed, then some output buffer
50 * handlers (e.g. for caching plugins) may fail to be able to store the page output in the object cache.
51 * See <https://github.com/WordPress/performance/pull/1317#issuecomment-2271955356>.
52 */
53 $flags = PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE;
54
55 ob_start(
56 static function ( string $output, ?int $phase ): string {
57 // When the output is being cleaned (e.g. the pending template is replaced with an error page), do not send it through the filter.
58 if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) {
59 return $output;
60 }
61
62 /**
63 * Filters the template output buffer before sending it to the client.
64 *
65 * @since 0.1.0
66 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_template_output_buffer
67 *
68 * @param string $output Output buffer.
69 * @return string Filtered output buffer.
70 */
71 return (string) apply_filters( 'od_template_output_buffer', $output );
72 },
73 0, // Unlimited buffer size.
74 $flags
75 );
76 return $passthrough;
77 }
78
79 /**
80 * Adds template output buffer filter for optimization if eligible.
81 *
82 * @since 0.1.0
83 * @access private
84 */
85 function od_maybe_add_template_output_buffer_filter(): void {
86 $disabled_reasons = od_get_disabled_reasons();
87 if ( count( $disabled_reasons ) > 0 ) {
88 if ( WP_DEBUG ) {
89 add_action(
90 'wp_print_footer_scripts',
91 static function () use ( $disabled_reasons ): void {
92 od_print_disabled_reasons( array_values( $disabled_reasons ) );
93 }
94 );
95 }
96 return;
97 }
98
99 $callback = 'od_optimize_template_output_buffer';
100 if (
101 function_exists( 'perflab_wrap_server_timing' )
102 &&
103 function_exists( 'perflab_server_timing_use_output_buffer' )
104 &&
105 perflab_server_timing_use_output_buffer()
106 ) {
107 $callback = perflab_wrap_server_timing( $callback, 'optimization-detective', 'exist' );
108 }
109 add_filter( 'od_template_output_buffer', $callback );
110 }
111
112 /**
113 * Prints the reasons why Optimization Detective is not optimizing the current page.
114 *
115 * This is only used when WP_DEBUG is enabled.
116 *
117 * @since 1.0.0
118 * @access private
119 *
120 * @param string[] $reasons Reason messages.
121 */
122 function od_print_disabled_reasons( array $reasons ): void {
123 foreach ( $reasons as $i => $reason ) {
124 wp_print_inline_script_tag(
125 sprintf(
126 "console.info( %s );\n//# sourceURL=od-print-disabled-reasons-%d",
127 (string) wp_json_encode( '[Optimization Detective] ' . $reason, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
128 $i + 1
129 ),
130 array( 'type' => 'module' )
131 );
132 }
133 }
134
135 /**
136 * Determines whether the current response can be optimized.
137 *
138 * @since 0.1.0
139 * @since 0.9.0 Response is optimized for admin users as well when in 'plugin' development mode.
140 *
141 * @access private
142 *
143 * @return bool Whether response can be optimized.
144 */
145 function od_can_optimize_response(): bool {
146 return count( od_get_disabled_reasons() ) === 0;
147 }
148
149 /**
150 * Determines whether the response has an HTML Content-Type.
151 *
152 * @since 0.2.0
153 * @private
154 *
155 * @return bool Whether Content-Type is HTML.
156 */
157 function od_is_response_html_content_type(): bool {
158 $is_html_content_type = false;
159
160 $headers_list = array_merge(
161 array( 'Content-Type: ' . ini_get( 'default_mimetype' ) ),
162 headers_list()
163 );
164 foreach ( $headers_list as $header ) {
165 $header_parts = preg_split( '/\s*[:;]\s*/', strtolower( $header ) );
166 if ( is_array( $header_parts ) && count( $header_parts ) >= 2 && 'content-type' === $header_parts[0] ) {
167 $is_html_content_type = in_array( $header_parts[1], array( 'text/html', 'application/xhtml+xml' ), true );
168 }
169 }
170
171 return $is_html_content_type;
172 }
173
174 /**
175 * Optimizes template output buffer.
176 *
177 * @since 0.1.0
178 * @access private
179 *
180 * @global WP_Query $wp_the_query WP_Query object.
181 *
182 * @param string $buffer Template output buffer.
183 * @return string Filtered template output buffer.
184 */
185 function od_optimize_template_output_buffer( string $buffer ): string {
186 global $wp_the_query;
187
188 // If the content-type is not HTML or the output does not start with '<', then abort since the buffer is definitely not HTML.
189 if (
190 ! od_is_response_html_content_type() ||
191 ! str_starts_with( ltrim( $buffer ), '<' )
192 ) {
193 return $buffer;
194 }
195
196 // If the initial tag is not an open HTML tag, then abort since the buffer is not a complete HTML document.
197 $processor = new OD_HTML_Tag_Processor( $buffer );
198 if ( ! (
199 $processor->next_tag( array( 'tag_closers' => 'visit' ) ) &&
200 ! $processor->is_tag_closer() &&
201 'HTML' === $processor->get_tag()
202 ) ) {
203 return $buffer;
204 }
205
206 $query_vars = od_get_normalized_query_vars();
207 $slug = od_get_url_metrics_slug( $query_vars );
208 $post = OD_URL_Metrics_Post_Type::get_post( $slug );
209
210 /**
211 * Post ID.
212 *
213 * @var positive-int|null $post_id
214 */
215 $post_id = $post instanceof WP_Post ? $post->ID : null;
216
217 $tag_visitor_registry = new OD_Tag_Visitor_Registry();
218
219 /**
220 * Fires to register tag visitors before walking over the document to perform optimizations.
221 *
222 * @since 0.3.0
223 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Action%3A%20od_register_tag_visitors
224 *
225 * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry.
226 */
227 do_action( 'od_register_tag_visitors', $tag_visitor_registry );
228
229 $current_etag = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
230 $group_collection = new OD_URL_Metric_Group_Collection(
231 $post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
232 $current_etag,
233 od_get_breakpoint_max_widths(),
234 od_get_url_metrics_breakpoint_sample_size(),
235 od_get_url_metric_freshness_ttl()
236 );
237 $link_collection = new OD_Link_Collection();
238
239 $template_optimization_context = new OD_Template_Optimization_Context(
240 $group_collection,
241 $link_collection,
242 $query_vars,
243 $slug,
244 $post_id
245 );
246
247 /**
248 * Fires before Optimization Detective starts iterating over the document in the output buffer.
249 *
250 * This is before any of the registered tag visitors have been invoked.
251 *
252 * @since 1.0.0
253 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Action%3A%20od_start_template_optimization
254 *
255 * @param OD_Template_Optimization_Context $template_optimization_context Template optimization context.
256 */
257 do_action( 'od_start_template_optimization', $template_optimization_context );
258
259 $visited_tag_state = new OD_Visited_Tag_State();
260 $tag_visitor_context = new OD_Tag_Visitor_Context(
261 $processor,
262 $group_collection,
263 $link_collection,
264 $visited_tag_state,
265 $post_id
266 );
267 $current_tag_bookmark = 'optimization_detective_current_tag';
268 $visitors = iterator_to_array( $tag_visitor_registry );
269
270 // Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected.
271 $needs_detection = ! $group_collection->is_every_group_complete();
272 $did_amend_meta_generator = false;
273 do {
274 // Never process anything inside NOSCRIPT since it will never show up in the DOM when scripting is enabled, and thus it can never be detected nor measured.
275 // Similarly, elements in the Admin Bar are not relevant for optimization, so this loop ensures that no tags in the Admin Bar are visited.
276 if (
277 in_array( 'NOSCRIPT', $processor->get_breadcrumbs(), true )
278 ||
279 $processor->is_admin_bar()
280 ) {
281 continue;
282 }
283
284 // Amend the META generator tag if it's the right one and hasn't been amended already.
285 if (
286 ! $did_amend_meta_generator && // @phpstan-ignore booleanNot.alwaysTrue, booleanAnd.alwaysFalse, booleanAnd.alwaysFalse, booleanAnd.alwaysFalse (False positives in PHPStan due to the following line.)
287 'META' === $processor->get_tag() && // @phpstan-ignore identical.alwaysFalse (False positive in PHPStan since it isn't aware of the do/while loop apparently.)
288 'generator' === $processor->get_attribute( 'name' ) &&
289 str_starts_with( (string) $processor->get_attribute( 'content' ), 'optimization-detective ' )
290 ) {
291 $content = (string) $processor->get_attribute( 'content' );
292 $viewport_group_status = array();
293 foreach ( $group_collection as $group ) {
294 $min_width = $group->get_minimum_viewport_width();
295
296 $status = 'empty';
297 if ( $group->is_complete() ) {
298 $status = 'complete';
299 } elseif ( $group->count() > 0 ) {
300 $status = 'populated';
301 }
302
303 $viewport_group_status[] = sprintf( '%s:%s', $min_width, $status );
304 }
305 $content .= '; url_metric_groups={' . implode( ', ', $viewport_group_status ) . '}';
306 $processor->set_attribute( 'content', $content );
307 $did_amend_meta_generator = true;
308 }
309
310 $tracked_in_url_metrics = false;
311 $processor->set_bookmark( $current_tag_bookmark ); // TODO: Should we break if this returns false?
312
313 foreach ( $visitors as $visitor ) {
314 $cursor_move_count = $processor->get_cursor_move_count();
315 $visitor_return_value = $visitor( $tag_visitor_context );
316 if ( true === $visitor_return_value ) {
317 $tracked_in_url_metrics = true;
318 }
319
320 // If the visitor traversed HTML tags, we need to go back to this tag so that in the next iteration any
321 // relevant tag visitors may apply, in addition to properly setting the data-od-xpath on this tag below.
322 if ( $cursor_move_count !== $processor->get_cursor_move_count() ) {
323 $processor->seek( $current_tag_bookmark ); // TODO: Should this break out of the optimization loop if it returns false?
324 }
325 }
326 $processor->release_bookmark( $current_tag_bookmark );
327
328 if ( $visited_tag_state->is_tag_tracked() ) {
329 $tracked_in_url_metrics = true;
330 }
331
332 if ( $tracked_in_url_metrics && $needs_detection ) {
333 $processor->set_meta_attribute( 'xpath', $processor->get_xpath() );
334 }
335
336 $visited_tag_state->reset();
337 } while ( $processor->next_tag( array( 'tag_closers' => 'skip' ) ) );
338
339 // Inject detection script.
340 // TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-od-xpath attributes.
341 if ( $needs_detection ) {
342 $processor->append_body_html( od_get_detection_scripts( $slug, $group_collection ) );
343 }
344
345 /**
346 * Fires after Optimization Detective has finished iterating over the document in the output buffer.
347 *
348 * This is after all the registered tag visitors have been invoked.
349 *
350 * @since 1.0.0
351 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Action%3A-,od_finish_template_optimization
352 *
353 * @param OD_Template_Optimization_Context $template_optimization_context Template optimization context.
354 */
355 do_action( 'od_finish_template_optimization', $template_optimization_context );
356
357 // Send any preload links in a Link response header and in a LINK tag injected at the end of the HEAD.
358 // Additional links may have been added at the od_finish_template_optimization action, so this must come after.
359 if ( count( $link_collection ) > 0 ) {
360 $response_header_links = $link_collection->get_response_header();
361 if ( ! is_null( $response_header_links ) && ! headers_sent() ) {
362 header( $response_header_links, false );
363 }
364 $processor->append_head_html( $link_collection->get_html() );
365 }
366
367 return $processor->get_updated_html();
368 }
369