content media count, since this is a main query * content element. This is accomplished by "increasing" * the count by zero, as the only way to get the count is * to call this function. * The actual count increase happens further below, based * on the `$increase_count` flag set here. */ $content_media_count = wp_increase_content_media_count( 0 ); $increase_count = true; // If the count so far is below the threshold, `loading` attribute is omitted. if ( $content_media_count < wp_omit_loading_attr_threshold() ) { $maybe_in_viewport = true; } else { $maybe_in_viewport = false; } } elseif ( // Only apply for main query but before the loop. $wp_query->before_loop && $wp_query->is_main_query() /* * Any image before the loop, but after the header has started should not be lazy-loaded, * except when the footer has already started which can happen when the current template * does not include any loop. */ && did_action( 'get_header' ) && ! did_action( 'get_footer' ) ) { $maybe_in_viewport = true; $maybe_increase_count = true; } } /* * If the element is in the viewport (`true`), potentially add * `fetchpriority` with a value of "high". Otherwise, i.e. if the element * is not in the viewport (`false`) or it is unknown (`null`), add * `loading` with a value of "lazy" if the element is not already being * de-prioritized with `fetchpriority=low` due to occlusion in * Navigation Overlay, non-initial carousel slides, or a collapsed Details block. */ if ( $maybe_in_viewport ) { $loading_attrs = wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ); } elseif ( ! $is_low_fetchpriority ) { // Only add `loading="lazy"` if the feature is enabled. if ( wp_lazy_loading_enabled( $tag_name, $context ) ) { $loading_attrs['loading'] = 'lazy'; } } /* * If flag was set based on contextual logic above, increase the content * media count, either unconditionally, or based on whether the image size * is larger than the threshold. This does not apply when the IMG has * fetchpriority=auto because it may be conditionally displayed by viewport * size. */ if ( 'auto' !== $existing_fetchpriority ) { if ( $increase_count ) { wp_increase_content_media_count(); } elseif ( $maybe_increase_count ) { /** This filter is documented in wp-includes/media.php */ $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { wp_increase_content_media_count(); } } } /** * Filters the loading optimization attributes. * * @since 6.4.0 * * @param array $loading_attrs The loading optimization attributes. * @param string $tag_name The tag name. * @param array $attr Array of the attributes for the tag. * @param string $context Context for the element for which the loading optimization attribute is requested. */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); } /** * Gets the threshold for how many of the first content media elements to not lazy-load. * * This function runs the {@see 'wp_omit_loading_attr_threshold'} filter, which uses a default threshold value of 3. * The filter is only run once per page load, unless the `$force` parameter is used. * * @since 5.9.0 * * @param bool $force Optional. If set to true, the filter will be (re-)applied even if it already has been before. * Default false. * @return int The number of content media elements to not lazy-load. */ function wp_omit_loading_attr_threshold( $force = false ) { static $omit_threshold; // This function may be called multiple times. Run the filter only once per page load. if ( ! isset( $omit_threshold ) || $force ) { /** * Filters the threshold for how many of the first content media elements to not lazy-load. * * For these first content media elements, the `loading` attribute will be omitted. By default, this is the case * for only the very first content media element. * * @since 5.9.0 * @since 6.3.0 The default threshold was changed from 1 to 3. * * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 3. */ $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 3 ); } return $omit_threshold; } /** * Increases an internal content media count variable. * * @since 5.9.0 * @access private * * @param int $amount Optional. Amount to increase by. Default 1. * @return int The latest content media count, after the increase. */ function wp_increase_content_media_count( $amount = 1 ) { static $content_media_count = 0; $content_media_count += $amount; return $content_media_count; } /** * Determines whether to add `fetchpriority='high'` to loading attributes. * * @since 6.3.0 * @since 7.0.0 Support is added for IMG tags with `fetchpriority='low'` and `fetchpriority='auto'`. * @access private * * @param array $loading_attrs Array of the loading optimization attributes for the element. * @param string $tag_name The tag name. * @param array $attr Array of the attributes for the element. * @return array Updated loading optimization attributes for the element. */ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ) { // For now, adding `fetchpriority="high"` is only supported for images. if ( 'img' !== $tag_name ) { return $loading_attrs; } $existing_fetchpriority = $attr['fetchpriority'] ?? null; if ( null !== $existing_fetchpriority && 'auto' !== $existing_fetchpriority ) { /* * When an IMG has been explicitly marked with `fetchpriority=high`, then honor that this is the element that * should have the priority. In contrast, the Navigation block may add `fetchpriority=low` to an IMG which * appears in the Navigation Overlay; such images should never be considered candidates for * `fetchpriority=high`. Lastly, block visibility may add `fetchpriority=auto` to an IMG when the block is * conditionally displayed based on viewport size. Such an image is considered an LCP element candidate if it * exceeds the threshold for the minimum number of square pixels. */ if ( 'high' === $existing_fetchpriority ) { $loading_attrs['fetchpriority'] = 'high'; wp_high_priority_element_flag( false ); } return $loading_attrs; } // Lazy-loading and `fetchpriority="high"` are mutually exclusive. if ( isset( $loading_attrs['loading'] ) && 'lazy' === $loading_attrs['loading'] ) { return $loading_attrs; } if ( ! wp_high_priority_element_flag() ) { return $loading_attrs; } /** * Filters the minimum square-pixels threshold for an image to be eligible as the high-priority image. * * @since 6.3.0 * * @param int $threshold Minimum square-pixels threshold. Default 50000. */ $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { if ( 'auto' !== $existing_fetchpriority ) { $loading_attrs['fetchpriority'] = 'high'; } wp_high_priority_element_flag( false ); } return $loading_attrs; } /** * Accesses a flag that indicates if an element is a possible candidate for `fetchpriority='high'`. * * @since 6.3.0 * @access private * * @param bool $value Optional. Used to change the static variable. Default null. * @return bool Returns true if the high-priority element was not already marked. */ function wp_high_priority_element_flag( $value = null ): bool { static $high_priority_element = true; if ( is_bool( $value ) ) { $high_priority_element = $value; } return $high_priority_element; } /** * Determines the output format for the image editor. * * @since 6.7.0 * @access private * * @param string $filename Path to the image. * @param string $mime_type The source image mime type. * @return array An array of mime type mappings. */ function wp_get_image_editor_output_format( $filename, $mime_type ) { $output_format = array( 'image/heic' => 'image/jpeg', 'image/heif' => 'image/jpeg', 'image/heic-sequence' => 'image/jpeg', 'image/heif-sequence' => 'image/jpeg', ); /** * Filters the image editor output format mapping. * * Enables filtering the mime type used to save images. By default HEIC/HEIF images * are converted to JPEGs. * * @see WP_Image_Editor::get_output_format() * * @since 5.8.0 * @since 6.7.0 The default was changed from an empty array to an array * containing the HEIC/HEIF images mime types. * * @param array $output_format An array of mime type mappings. Maps a source mime type to a new * destination mime type. By default maps HEIC/HEIF input to JPEG output. * @param string $filename Path to the image. * @param string $mime_type The source image mime type. */ return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type ); } /** * Checks whether client-side media processing is enabled. * * Client-side media processing uses the browser's capabilities to handle * tasks like image resizing and compression before uploading to the server. * * @since 7.1.0 * * @return bool Whether client-side media processing is enabled. */ function wp_is_client_side_media_processing_enabled(): bool { // This is due to SharedArrayBuffer requiring a secure context. $host = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) ); $enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) ); /** * Filters whether client-side media processing is enabled. * * @since 7.1.0 * * @param bool $enabled Whether client-side media processing is enabled. Default true if the page is served in a secure context. */ return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled ); } /** * Sets a global JS variable to indicate that client-side media processing is enabled. * * @since 7.1.0 */ function wp_set_client_side_media_processing_flag(): void { if ( ! wp_is_client_side_media_processing_enabled() ) { return; } wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' ); $chromium_version = wp_get_chromium_major_version(); if ( null !== $chromium_version && $chromium_version >= 137 ) { wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' ); } } /** * Returns the major Chrome/Chromium version from the current request's User-Agent. * * Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave). * * @since 7.1.0 * * @return int|null The major Chrome version, or null if not a Chromium browser. */ function wp_get_chromium_major_version(): ?int { if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) { return null; } if ( preg_match( '#Chrome/(\d+)#', $_SERVER['HTTP_USER_AGENT'], $matches ) ) { return (int) $matches[1]; } return null; } /** * Enables cross-origin isolation in the block editor. * * Required for enabling SharedArrayBuffer for WebAssembly-based * media processing in the editor. Uses Document-Isolation-Policy * on supported browsers (Chromium 137+). * * Skips setup when a third-party page builder overrides the block * editor via a custom `action` query parameter, as DIP would block * same-origin iframe access that these editors rely on. * * @since 7.1.0 */ function wp_set_up_cross_origin_isolation(): void { if ( ! wp_is_client_side_media_processing_enabled() ) { return; } $screen = get_current_screen(); if ( ! $screen ) { return; } if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) { return; } /* * Skip when rendering the classic-theme home route, which shows the site * preview in an iframe and must reach its `contentDocument` to neutralize * interactive elements. DIP would block that same-origin access. * * Keyed off $pagenow rather than the current screen so the guard keeps * working if the header set-up is ever moved to an earlier hook (such as * admin_init) where the screen is not yet available. */ global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'site-editor.php' === $pagenow && ! wp_is_block_theme() && ( ! isset( $_GET['p'] ) || '/' === $_GET['p'] ) ) { return; } /* * Skip when a third-party page builder overrides the block editor. * DIP isolates the document into its own agent cluster, * which blocks same-origin iframe access that these editors rely on. */ if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) { return; } // Cross-origin isolation is not needed if users can't upload files anyway. if ( ! current_user_can( 'upload_files' ) ) { return; } wp_start_cross_origin_isolation_output_buffer(); } /** * Sends the Document-Isolation-Policy header for cross-origin isolation. * * Uses an output buffer to add crossorigin="anonymous" where needed. * * @since 7.1.0 */ function wp_start_cross_origin_isolation_output_buffer(): void { $chromium_version = wp_get_chromium_major_version(); if ( null === $chromium_version || $chromium_version < 137 ) { return; } ob_start( static function ( string $output ): string { header( 'Document-Isolation-Policy: isolate-and-credentialless' ); return wp_add_crossorigin_attributes( $output ); } ); } /** * Adds crossorigin="anonymous" to relevant tags in the given HTML string. * * @since 7.1.0 * * @param string $html HTML input. * @return string Modified HTML. */ function wp_add_crossorigin_attributes( string $html ): string { $site_url = site_url(); $processor = new WP_HTML_Tag_Processor( $html ); // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin. $cross_origin_tag_attributes = array( 'AUDIO' => array( 'src' ), 'LINK' => array( 'href' ), 'SCRIPT' => array( 'src' ), 'VIDEO' => array( 'src', 'poster' ), 'SOURCE' => array( 'src' ), ); while ( $processor->next_tag() ) { $tag = $processor->get_tag(); if ( ! isset( $cross_origin_tag_attributes[ $tag ] ) ) { continue; } $crossorigin = $processor->get_attribute( 'crossorigin' ); if ( null !== $crossorigin ) { continue; } if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) { $processor->set_bookmark( 'audio-video-parent' ); } $processor->set_bookmark( 'resume' ); $sought = false; $is_cross_origin = false; foreach ( $cross_origin_tag_attributes[ $tag ] as $attr ) { $url = $processor->get_attribute( $attr ); if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) ) { $is_cross_origin = true; } if ( $is_cross_origin ) { break; } } if ( $is_cross_origin ) { if ( 'SOURCE' === $tag ) { $sought = $processor->seek( 'audio-video-parent' ); if ( $sought ) { $processor->set_attribute( 'crossorigin', 'anonymous' ); } } else { $processor->set_attribute( 'crossorigin', 'anonymous' ); } if ( $sought ) { $processor->seek( 'resume' ); $processor->release_bookmark( 'audio-video-parent' ); } } } return $processor->get_updated_html(); }