w3tc_config = $w3tc_config; $this->posts_by_url = $posts_by_url; } /** * Runs the lazy loading mutator process on the provided buffer. * * @param string $buffer The HTML content to process for lazy loading. * * @return string The modified buffer with lazy loading applied. */ public function run( $buffer ) { $this->excludes = apply_filters( 'w3tc_lazyload_excludes', $this->w3tc_config->get_array( 'lazyload.exclude' ) ); $w3tc_r = apply_filters( 'w3tc_lazyload_mutator_before', array( 'buffer' => $buffer, 'modified' => $this->modified, ) ); $buffer = $w3tc_r['buffer']; $this->modified = $w3tc_r['modified']; $unmutable = new UserExperience_LazyLoad_Mutator_Unmutable(); $buffer = $unmutable->remove_unmutable( $buffer ); if ( $this->w3tc_config->get_boolean( 'lazyload.process_img' ) ) { $buffer = preg_replace_callback( '~]+)*>(.*?)~is', array( $this, 'tag_picture' ), $buffer ); $buffer = preg_replace_callback( '~]+>~is', array( $this, 'tag_img' ), $buffer ); } if ( $this->w3tc_config->get_boolean( 'lazyload.process_background' ) ) { $buffer = preg_replace_callback( '~<[^>]+background(-image)?:\s*url[^>]+>~is', array( $this, 'tag_with_background' ), $buffer ); } $buffer = $unmutable->restore_unmutable( $buffer ); return $buffer; } /** * Checks if the content has been modified during processing. * * @return bool True if the content was modified; otherwise, false. */ public function content_modified() { return $this->modified; } /** * Processes tags for lazy loading. * * @param array $matches Regex matches for the tag. * * @return string The modified tag with lazy loading applied. */ public function tag_picture( $matches ) { $content = $matches[0]; if ( $this->is_content_excluded( $content ) ) { return $content; } $m = new UserExperience_LazyLoad_Mutator_Picture( $this ); return $m->run( $content ); } /** * Processes tags for lazy loading. * * @param array $matches Regex matches for the tag. * * @return string The modified tag with lazy loading applied. */ public function tag_img( $matches ) { $content = $matches[0]; if ( $this->is_content_excluded( $content ) ) { return $content; } // get image dimensions. $dim = $this->tag_get_dimensions( $content ); return $this->tag_img_content_replace( $content, $dim ); } /** * Replaces content with placeholders and adds lazy loading attributes. * * @param string $content The tag content. * @param array $dim The dimensions of the image (width and height). * * @return string The modified tag. */ public function tag_img_content_replace( $content, $dim ) { $placeholder = $this->placeholder( $dim['w'], $dim['h'] ); $w3tc_count = 0; $content = $this->replace_top_level_quoted_attributes( $content, 'src', static function ( $whitespace, $name, $quoted_value ) use ( $placeholder ) { return $whitespace . 'src="' . $placeholder . '" data-src=' . $quoted_value; }, 1, $w3tc_count ); if ( $w3tc_count > 0 ) { $content = $this->replace_top_level_quoted_attributes( $content, 'srcset|sizes', static function ( $whitespace, $name, $quoted_value ) { return $whitespace . 'data-' . $name . '=' . $quoted_value; } ); $content = $this->add_class_lazy( $content ); $content = $this->remove_native_lazy( $content ); $this->modified = true; } return $content; } /** * Extracts image dimensions from content or associated metadata. * * @param string $content The tag content. * * @return array The dimensions of the image (width and height). */ public function tag_get_dimensions( $content ) { $dim = array( 'w' => 1, 'h' => 1, ); $m = null; if ( preg_match( '~\swidth=[\s\'"]*([0-9]+)~is', $content, $m ) ) { $dim['w'] = (int) $m[1]; $dim['h'] = $dim['w']; if ( preg_match( '~\sheight=[\s\'"]*([0-9]+)~is', $content, $m ) ) { $dim['h'] = (int) $m[1]; return $dim; } } // if not in attributes - try to find via url. $w3tc_url = $this->get_top_level_quoted_attribute_value( $content, 'src' ); if ( null === $w3tc_url ) { return $dim; } // full url found. if ( isset( $this->posts_by_url[ $w3tc_url ] ) ) { $post_id = $this->posts_by_url[ $w3tc_url ]; $image = wp_get_attachment_image_src( $post_id, 'full' ); if ( $image ) { $dim['w'] = $image[1]; $dim['h'] = $image[2]; } return $dim; } // try resized url by format. static $base_url = null; if ( is_null( $base_url ) ) { $base_url = wp_get_upload_dir()['baseurl']; } if ( substr( $w3tc_url, 0, strlen( $base_url ) ) === $base_url && preg_match( '~(.+)-(\\d+)x(\\d+)(\\.[a-z0-9]+)$~is', $w3tc_url, $m ) ) { $dim['w'] = (int) $m[2]; $dim['h'] = (int) $m[3]; } return $dim; } /** * Processes elements with background styles for lazy loading. * * @param array $matches Regex matches for elements with background styles. * * @return string The modified element with lazy loading applied. */ public function tag_with_background( $matches ) { $content = $matches[0]; if ( $this->is_content_excluded( $content ) ) { return $content; } $style = $this->get_top_level_quoted_attribute_value( $content, 'style' ); if ( null === $style || ! \preg_match( '~background(?:-image)?:\s*url\s*\(~is', $style ) ) { return $content; } $updated = $this->replace_top_level_quoted_attributes( $content, 'style', function ( $whitespace, $name, $quoted_value ) { $quote = \substr( $quoted_value, 0, 1 ); $inner = \substr( $quoted_value, 1, -1 ); return $this->style_offload_background( array( $whitespace . $name . '=' . $quoted_value, $whitespace, $name . '=' . $quote, $inner, $quote, ) ); }, 1 ); if ( $updated !== $content ) { $content = $this->add_class_lazy( $updated ); $this->modified = true; } return $content; } /** * Offloads background styles for lazy loading. * * @param array $matches Regex matches for background styles. * * @return string The modified style attribute with lazy loading applied. */ public function style_offload_background( $matches ) { list( $w3tc_match, $v1, $v2, $v, $quote ) = $matches; $url_match = null; \preg_match( '~background(?:-image)?:\s*url\(([\"\']?)(.+?)\1\)~is', $v, $url_match ); $raw_url = ''; if ( isset( $url_match[2] ) ) { $charset = \get_bloginfo( 'charset' ); $raw_url = \trim( \html_entity_decode( $url_match[2], ENT_QUOTES, $charset ) ); $raw_url = \trim( $raw_url, '\'"' ); } if ( \preg_match( '~^([a-z][a-z0-9+.-]*):~i', $raw_url, $proto ) ) { $scheme = \strtolower( $proto[1] ); if ( 'http' !== $scheme && 'https' !== $scheme ) { return $w3tc_match; } } if ( '' === $raw_url ) { return $w3tc_match; } $v = \preg_replace( '~background(?:-image)?:\s*url\(([\"\']?).+?\1\)[^;]*;?\s*~is', '', $v ); return $v1 . $v2 . $v . $quote . ' data-bg="' . \esc_attr( $raw_url ) . '"'; } /** * Adds the "lazy" class to the provided content if not already present. * * @param string $content The content to modify. * * @return string The modified content with the "lazy" class applied. */ private function add_class_lazy( $content ) { $w3tc_count = 0; $content = $this->replace_top_level_quoted_attributes( $content, 'class', function ( $whitespace, $name, $quoted_value ) { $quote = \substr( $quoted_value, 0, 1 ); $inner = \substr( $quoted_value, 1, -1 ); return $this->class_process( array( $whitespace . $name . '=' . $quoted_value, $whitespace, $name . '=', $quote, $inner, ) ); }, -1, $w3tc_count ); if ( $w3tc_count <= 0 ) { $content = \preg_replace( '~<(\S+)(\s+)~is', '<$1$2class="lazy" ', $content ); } return $content; } /** * Removes native lazy loading attributes from the content. * * @param string $content The content to modify. * * @return string The modified content with native lazy loading removed. */ public function remove_native_lazy( $content ) { return $this->replace_top_level_quoted_attributes( $content, 'loading', static function ( $whitespace, $name, $quoted_value ) { $inner = \substr( $quoted_value, 1, -1 ); if ( 0 === \strcasecmp( $inner, 'lazy' ) ) { return ''; } return $whitespace . $name . '=' . $quoted_value; } ); } /** * Processes class attributes to include the "lazy" class. * * @param array $matches Regex matches for class attributes. * * @return string The modified class attribute. */ public function class_process( $matches ) { list( $w3tc_match, $v1, $v2, $quote, $v ) = $matches; if ( preg_match( '~(^|\\s)lazy(\\s|$)~is', $v ) ) { return $w3tc_match; } $v .= ' lazy'; return $v1 . $v2 . $quote . $v . $quote; } /** * Determines if the content should be excluded from lazy loading. * * @param string $content The content to check. * * @return bool True if the content is excluded; otherwise, false. */ private function is_content_excluded( $content ) { foreach ( $this->excludes as $w ) { if ( ! empty( $w ) ) { if ( strpos( $content, $w ) !== false ) { return true; } } } return false; } /** * Generates a placeholder SVG for an image with the given dimensions. * * @param int $w The width of the image. * @param int $h The height of the image. * * @return string The SVG placeholder. */ public function placeholder( $w, $h ) { $svg = ''; return 'data:image/svg+xml,' . rawurlencode( $svg ); } /** * Replace top-level quoted attributes, skipping matches inside other attribute values. * * @since 2.10.4 * * @param string $content Tag markup to rewrite. * @param string $attr_pattern Attribute name or alternation (e.g. `src` or `srcset|sizes`). * @param callable $replacer Callback `( $whitespace, $name, $quoted_value ) => string`. * @param int $limit Max replacements; -1 for all. * @param int $count Set to the number of replacements performed. * * @return string */ public function replace_top_level_quoted_attributes( $content, $attr_pattern, $replacer, $limit = -1, &$count = 0 ) { $count = 0; if ( ! preg_match_all( '~(\s)(' . $attr_pattern . ')=(\'([^\']*)\'|"([^"]*)")~is', $content, $matches, PREG_OFFSET_CAPTURE ) ) { return $content; } $replacements = array(); foreach ( $matches[0] as $i => $full ) { if ( $limit >= 0 && \count( $replacements ) >= $limit ) { break; } $offset = $full[1]; if ( ! $this->is_outside_attribute_value( $content, $offset ) ) { continue; } $replacements[] = array( 'offset' => $offset, 'length' => \strlen( $full[0] ), 'replacement' => \call_user_func( $replacer, $matches[1][ $i ][0], $matches[2][ $i ][0], $matches[3][ $i ][0] ), ); } for ( $i = \count( $replacements ) - 1; $i >= 0; $i-- ) { $content = \substr_replace( $content, $replacements[ $i ]['replacement'], $replacements[ $i ]['offset'], $replacements[ $i ]['length'] ); ++$count; } return $content; } /** * Read the first top-level quoted attribute value, or null if none. * * @since 2.10.4 * * @param string $content Tag markup. * @param string $attr Attribute name. * * @return string|null */ public function get_top_level_quoted_attribute_value( $content, $attr ) { if ( ! preg_match_all( '~(\s)' . \preg_quote( $attr, '~' ) . '=(\'([^\']*)\'|"([^"]*)")~is', $content, $matches, PREG_OFFSET_CAPTURE ) ) { return null; } foreach ( $matches[0] as $i => $full ) { if ( ! $this->is_outside_attribute_value( $content, $full[1] ) ) { continue; } $quoted = $matches[2][ $i ][0]; return \substr( $quoted, 1, -1 ); } return null; } /** * Whether the byte offset sits outside a quoted HTML attribute value. * * @since 2.10.4 * * @param string $content Tag markup. * @param int $offset Byte offset into $content. * * @return bool */ private function is_outside_attribute_value( $content, $offset ) { $in = null; for ( $i = 0; $i < $offset; $i++ ) { $ch = $content[ $i ]; if ( null === $in ) { if ( '"' === $ch || "'" === $ch ) { $in = $ch; } } elseif ( $ch === $in ) { $in = null; } } return null === $in; } }