HEX
Server: Apache/2.4.65 (Debian)
System: Linux 88f31f35b0b8 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
User: www-data (33)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: //proc/self/cwd/wp-content/plugins/optimization-detective/class-od-link-collection.php
<?php
/**
 * Optimization Detective: OD_Link_Collection class
 *
 * @package optimization-detective
 * @since 0.3.0
 */

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

/**
 * Collection for links added to the document.
 *
 * @phpstan-type Link array{
 *                   attributes: LinkAttributes,
 *                   minimum_viewport_width: int<0, max>|null,
 *                   maximum_viewport_width: int<1, max>|null
 *               }
 *
 * @phpstan-type LinkAttributes array{
 *                   rel: 'preload'|'modulepreload'|'preconnect'|'dns-prefetch',
 *                   href?: non-empty-string,
 *                   imagesrcset?: non-empty-string,
 *                   imagesizes?: non-empty-string,
 *                   crossorigin?: 'anonymous'|'use-credentials',
 *                   fetchpriority?: 'high'|'low'|'auto',
 *                   as?: 'audio'|'document'|'embed'|'fetch'|'font'|'image'|'object'|'script'|'style'|'track'|'video'|'worker',
 *                   media?: non-empty-string,
 *                   type?: non-empty-string,
 *                   integrity?: non-empty-string,
 *                   referrerpolicy?: 'no-referrer'|'no-referrer-when-downgrade'|'origin'|'origin-when-cross-origin'|'unsafe-url'
 *               }
 *
 * @since 0.3.0
 * @since 0.4.0 Renamed from OD_Preload_Link_Collection.
 * @since 1.0.0 Added support for dns-prefetch.
 */
final class OD_Link_Collection implements Countable {

	/**
	 * Links grouped by rel type.
	 *
	 * @since 0.4.0
	 *
	 * @var array<string, Link[]>
	 */
	private $links_by_rel = array();

	/**
	 * Adds link.
	 *
	 * @since 0.3.0
	 * @since 1.0.0 Added support for dns-prefetch.
	 *
	 * @phpstan-param LinkAttributes $attributes
	 *
	 * @param array            $attributes             Attributes.
	 * @param int<0, max>|null $minimum_viewport_width Minimum width (exclusive) or null if not bounded or relevant.
	 * @param int<1, max>|null $maximum_viewport_width Maximum width (inclusive) or null if not bounded (i.e. infinity) or relevant.
	 *
	 * @throws InvalidArgumentException When invalid arguments are provided.
	 */
	public function add_link( array $attributes, ?int $minimum_viewport_width = null, ?int $maximum_viewport_width = null ): void {
		$throw_invalid_argument_exception = static function ( string $message ): void {
			throw new InvalidArgumentException( esc_html( $message ) );
		};
		if ( ! array_key_exists( 'rel', $attributes ) ) {
			$throw_invalid_argument_exception(
				/* translators: %s: rel */
				sprintf( __( 'The "%s" attribute must be provided.', 'optimization-detective' ), 'rel' )
			);
		}
		if ( 'preload' === $attributes['rel'] && ! array_key_exists( 'as', $attributes ) ) {
			$throw_invalid_argument_exception(
				/* translators: 1: link, 2: rel=preload, 3: 'as' attribute name */
				sprintf( __( 'A %1$s with %2$s must include an "%3$s" attribute.', 'optimization-detective' ), 'link', 'rel=preload', 'as' )
			);
		} elseif ( 'preconnect' === $attributes['rel'] && ! array_key_exists( 'href', $attributes ) ) {
			$throw_invalid_argument_exception(
				/* translators: 1: link, 2: rel=preconnect, 3: 'href' attribute name */
				sprintf( __( 'A %1$s with %2$s must include an "%3$s" attribute.', 'optimization-detective' ), 'link', 'rel=preconnect', 'href' )
			);
		} elseif ( 'dns-prefetch' === $attributes['rel'] && ! array_key_exists( 'href', $attributes ) ) {
			$throw_invalid_argument_exception(
				/* translators: 1: link, 2: rel=dns-prefetch, 3: 'href' attribute name */
				sprintf( __( 'A %1$s with %2$s must include an "%3$s" attribute.', 'optimization-detective' ), 'link', 'rel=dns-prefetch', 'href' )
			);
		}
		if ( ! array_key_exists( 'href', $attributes ) && ! array_key_exists( 'imagesrcset', $attributes ) ) {
			$throw_invalid_argument_exception(
				/* translators: 1: 'href' attribute name, 2: 'imagesrcset' attribute name */
				sprintf( __( 'Either the "%1$s" or "%2$s" attribute must be supplied.', 'optimization-detective' ), 'href', 'imagesrcset' )
			);
		}
		if ( null !== $minimum_viewport_width && $minimum_viewport_width < 0 ) {
			$throw_invalid_argument_exception( __( 'Minimum width must be at least zero.', 'optimization-detective' ) );
		}
		if ( null !== $maximum_viewport_width && ( $maximum_viewport_width < $minimum_viewport_width || $maximum_viewport_width < 0 ) ) {
			$throw_invalid_argument_exception( __( 'Maximum width must be greater than zero and greater than the minimum width.', 'optimization-detective' ) );
		}
		foreach ( array( 'rel', 'href', 'imagesrcset', 'imagesizes', 'crossorigin', 'fetchpriority', 'as', 'integrity', 'referrerpolicy' ) as $attribute_name ) {
			if ( array_key_exists( $attribute_name, $attributes ) && ! is_string( $attributes[ $attribute_name ] ) ) {
				$throw_invalid_argument_exception( __( 'Link attributes must be strings.', 'optimization-detective' ) );
			}
		}

		$this->links_by_rel[ $attributes['rel'] ][] = array(
			'attributes'             => $attributes,
			'minimum_viewport_width' => $minimum_viewport_width,
			'maximum_viewport_width' => $maximum_viewport_width,
		);
	}

	/**
	 * Prepares links by deduplicating adjacent links and adding media attributes.
	 *
	 * When two links are identical except for their minimum/maximum widths which are also consecutive, then merge them
	 * together. Also, add media attributes to the links.
	 *
	 * @since 0.4.0
	 *
	 * @return LinkAttributes[] Prepared links with adjacent-duplicates merged together and media attributes added.
	 */
	private function get_prepared_links(): array {
		$links_by_rel = array_values( $this->links_by_rel );
		if ( count( $links_by_rel ) === 0 ) {
			// This condition is needed for PHP 7.2 and PHP 7.3 in which array_merge() fails if passed a spread empty array: 'array_merge() expects at least 1 parameter, 0 given'.
			return array();
		}

		return array_merge(
			...array_map(
				function ( array $links ): array {
					return $this->merge_consecutive_links( $links );
				},
				$links_by_rel
			)
		);
	}

	/**
	 * Merges consecutive links.
	 *
	 * @since 0.4.0
	 *
	 * @param Link[] $links Links.
	 * @return LinkAttributes[] Merged consecutive links.
	 */
	private function merge_consecutive_links( array $links ): array {

		usort(
			$links,
			/**
			 * Comparator.
			 *
			 * The links are sorted first by the 'href' attribute to group identical URLs together.
			 * If the 'href' attributes are the same, the links are then sorted by 'minimum_viewport_width'.
			 *
			 * @param Link $a First link.
			 * @param Link $b Second link.
			 * @return int Comparison result.
			 */
			static function ( array $a, array $b ): int {
				// Get href values, defaulting to empty string if not present.
				$href_a = $a['attributes']['href'] ?? '';
				$href_b = $b['attributes']['href'] ?? '';

				$href_comparison = strcmp( $href_a, $href_b );
				if ( 0 === $href_comparison ) {
					return $a['minimum_viewport_width'] <=> $b['minimum_viewport_width'];
				}

				return $href_comparison;
			}
		);

		/**
		 * Deduplicated adjacent links.
		 *
		 * @var Link[] $prepared_links
		 */
		$prepared_links = array_reduce(
			$links,
			/**
			 * Reducer.
			 *
			 * @param array<int, Link> $carry Carry.
			 * @param Link $link Link.
			 * @return non-empty-array<int, Link> Potentially-reduced links.
			 */
			static function ( array $carry, array $link ): array {
				/**
				 * Last link.
				 *
				 * @var Link $last_link
				 */
				$last_link = end( $carry );
				if (
					is_array( $last_link )
					&&
					$last_link['attributes'] === $link['attributes']
					&&
					is_int( $last_link['minimum_viewport_width'] )
					&&
					is_int( $last_link['maximum_viewport_width'] )
					&&
					$last_link['maximum_viewport_width'] === $link['minimum_viewport_width']
				) {
					$last_link['maximum_viewport_width'] = null === $link['maximum_viewport_width'] ? null : max( $last_link['maximum_viewport_width'], $link['maximum_viewport_width'] );

					// Update the last link with the new maximum viewport width.
					$carry[ count( $carry ) - 1 ] = $last_link;
				} else {
					$carry[] = $link;
				}
				return $carry;
			},
			array()
		);

		// Add media attributes to the deduplicated links.
		return array_map(
			static function ( array $link ): array {
				$media_query = od_generate_media_query( $link['minimum_viewport_width'], $link['maximum_viewport_width'] );
				if ( null !== $media_query ) {
					if ( ! isset( $link['attributes']['media'] ) ) {
						$link['attributes']['media'] = $media_query;
					} else {
						$link['attributes']['media'] .= " and $media_query";
					}
				}
				return $link['attributes'];
			},
			$prepared_links
		);
	}

	/**
	 * Gets the HTML for the link tags.
	 *
	 * @since 0.3.0
	 *
	 * @return string Link tags HTML.
	 */
	public function get_html(): string {
		$link_tags = array();

		foreach ( $this->get_prepared_links() as $link ) {
			$link_tag = '<link data-od-added-tag';
			foreach ( $link as $name => $value ) {
				$link_tag .= sprintf( ' %s="%s"', $name, esc_attr( $value ) );
			}
			$link_tag .= ">\n";

			$link_tags[] = $link_tag;
		}

		return implode( '', $link_tags );
	}

	/**
	 * Constructs the Link HTTP response header.
	 *
	 * @since 0.4.0
	 *
	 * @return non-empty-string|null Link HTTP response header, or null if there are none.
	 */
	public function get_response_header(): ?string {
		$link_headers = array();

		foreach ( $this->get_prepared_links() as $link ) {
			if ( isset( $link['href'] ) ) {
				$link['href'] = $this->encode_url_for_response_header( $link['href'] );
			} else {
				// The about:blank is present since a Link without a reference-uri is invalid so any imagesrcset would otherwise not get downloaded.
				$link['href'] = 'about:blank';
			}

			// Encode the URLs in the srcset.
			if ( isset( $link['imagesrcset'] ) ) {
				$link['imagesrcset'] = join(
					', ',
					array_map(
						function ( $image_candidate ) {
							// Parse out the URL to separate it from the descriptor.
							$image_candidate_parts = (array) preg_split( '/\s+/', (string) $image_candidate, 2 );

							// Encode the URL.
							$image_candidate_parts[0] = $this->encode_url_for_response_header( (string) $image_candidate_parts[0] );

							// Re-join the URL with the descriptor.
							return implode( ' ', $image_candidate_parts );
						},
						(array) preg_split( '/\s*,\s*/', $link['imagesrcset'] )
					)
				);
			}

			$link_header = '<' . $link['href'] . '>';
			unset( $link['href'] );
			foreach ( $link as $name => $value ) {
				/*
				 * Escape the value being put into an HTTP quoted string. The grammar is:
				 *
				 *     quoted-string  = DQUOTE *( qdtext / quoted-pair ) DQUOTE
				 *     qdtext         = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
				 *     quoted-pair    = "\" ( HTAB / SP / VCHAR / obs-text )
				 *     obs-text       = %x80-FF
				 *
				 * See <https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.4>. So to escape a value we need to add
				 * a backslash in front of anything character which is not qdtext.
				 */
				$escaped_value = preg_replace( '/(?=[^\t \x21\x23-\x5B\x5D-\x7E\x80-\xFF])/', '\\\\', $value );
				$link_header  .= sprintf( '; %s="%s"', $name, $escaped_value );
			}

			$link_headers[] = $link_header;
		}
		if ( count( $link_headers ) === 0 ) {
			return null;
		}

		return 'Link: ' . implode( ', ', $link_headers );
	}

	/**
	 * Encodes a URL for serving in an HTTP response header.
	 *
	 * @since 1.0.0
	 *
	 * @param string $url URL to percent encode.
	 * @return string Percent-encoded URL.
	 */
	private function encode_url_for_response_header( string $url ): string {
		// Encode characters not allowed in a URL per RFC 3986 (anything that is not among the reserved and unreserved characters).
		$encoded_url = (string) preg_replace_callback(
			'/[^A-Za-z0-9\-._~:\/?#\[\]@!$&\'()*+,;=%]/',
			static function ( $matches ) {
				return rawurlencode( $matches[0] );
			},
			$url
		);
		return esc_url_raw( $encoded_url );
	}

	/**
	 * Counts the links.
	 *
	 * @since 0.3.0
	 *
	 * @return non-negative-int Link count.
	 */
	public function count(): int {
		return array_sum(
			array_map(
				static function ( array $links ): int {
					return count( $links );
				},
				array_values( $this->links_by_rel )
			)
		);
	}
}