<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[51973] trunk: REST API: Add URL Details endpoint.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/51973">51973</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/51973","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>hellofromTonya</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2021-11-02 12:46:01 +0000 (Tue, 02 Nov 2021)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>REST API: Add URL Details endpoint.

Adds a new REST API endpoint (`/wp-block-editor/v1/url-details`) for retrieving information from an external URL.

Information retrieved:

* Title: content of the `<title>` element
* Icon: favicon image link
* Description: content of the `description` or `og:description` meta element
* Image: OG image link

This endpoint is used by the block editor for link previews.

Props get_dave, aduth, andraganescu, beaulebens, hellofromTonya, kevin940726, mamaduka, marekhrabe, mnelson4, noisysocks, obenland, ocean90, retrofox, shaunandrews, spacedmonkey, swissspidy, timothyblynjacobs, xknown, youknowriad.
Fixes <a href="https://core.trac.wordpress.org/ticket/54358">#54358</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesrestapiphp">trunk/src/wp-includes/rest-api.php</a></li>
<li><a href="#trunksrcwpsettingsphp">trunk/src/wp-settings.php</a></li>
<li><a href="#trunktestsqunitfixtureswpapigeneratedjs">trunk/tests/qunit/fixtures/wp-api-generated.js</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesrestapiendpointsclasswpresturldetailscontrollerphp">trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php</a></li>
<li><a href="#trunktestsphpunittestsrestapiwpRestUrlDetailsControllerphp">trunk/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludesrestapiendpointsclasswpresturldetailscontrollerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php                         (rev 0)
+++ trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php   2021-11-02 12:46:01 UTC (rev 51973)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,630 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * REST API: WP_REST_URL_Details_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Controller which provides REST endpoint for retrieving information
+ * from a remote site's HTML response.
+ *
+ * @since 5.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_URL_Details_Controller extends WP_REST_Controller {
+
+       /**
+        * Constructs the controller.
+        *
+        * @since 5.9.0
+        */
+       public function __construct() {
+               $this->namespace = 'wp-block-editor/v1';
+               $this->rest_base = 'url-details';
+       }
+
+       /**
+        * Registers the necessary REST API routes.
+        *
+        * @since 5.9.0
+        */
+       public function register_routes() {
+               register_rest_route(
+                       $this->namespace,
+                       '/' . $this->rest_base,
+                       array(
+                               array(
+                                       'methods'             => WP_REST_Server::READABLE,
+                                       'callback'            => array( $this, 'parse_url_details' ),
+                                       'args'                => array(
+                                               'url' => array(
+                                                       'required'          => true,
+                                                       'description'       => __( 'The URL to process.' ),
+                                                       'validate_callback' => 'wp_http_validate_url',
+                                                       'sanitize_callback' => 'esc_url_raw',
+                                                       'type'              => 'string',
+                                                       'format'            => 'uri',
+                                               ),
+                                       ),
+                                       'permission_callback' => array( $this, 'permissions_check' ),
+                                       'schema'              => array( $this, 'get_public_item_schema' ),
+                               ),
+                       )
+               );
+       }
+
+       /**
+        * Retrieves the item's schema, conforming to JSON Schema.
+        *
+        * @since 5.9.0
+        *
+        * @return array Item schema data.
+        */
+       public function get_item_schema() {
+               if ( $this->schema ) {
+                       return $this->add_additional_fields_schema( $this->schema );
+               }
+
+               $schema = array(
+                       '$schema'    => 'http://json-schema.org/draft-04/schema#',
+                       'title'      => 'url-details',
+                       'type'       => 'object',
+                       'properties' => array(
+                               'title'       => array(
+                                       'description' => __( 'The contents of the <title> element from the URL.' ),
+                                       'type'        => 'string',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                               'icon'        => array(
+                                       'description' => __( 'The favicon image link of the <link rel="icon"> element from the URL.' ),
+                                       'type'        => 'string',
+                                       'format'      => 'uri',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                               'description' => array(
+                                       'description' => __( 'The content of the <meta name="description"> element from the URL.' ),
+                                       'type'        => 'string',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                               'image'       => array(
+                                       'description' => __( 'The OG image link of the <meta property="og:image"> or <meta property="og:image:url"> element from the URL.' ),
+                                       'type'        => 'string',
+                                       'format'      => 'uri',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                       ),
+               );
+
+               $this->schema = $schema;
+
+               return $this->add_additional_fields_schema( $this->schema );
+       }
+
+       /**
+        * Retrieves the contents of the <title> tag from the HTML response.
+        *
+        * @since 5.9.0
+        *
+        * @param WP_REST_REQUEST $request Full details about the request.
+        * @return WP_REST_Response|WP_Error The parsed details as a response object, or an error.
+        */
+       public function parse_url_details( $request ) {
+               $url = untrailingslashit( $request['url'] );
+
+               if ( empty( $url ) ) {
+                       return new WP_Error( 'rest_invalid_url', __( 'Invalid URL' ), array( 'status' => 404 ) );
+               }
+
+               // Transient per URL.
+               $cache_key = $this->build_cache_key_for_url( $url );
+
+               // Attempt to retrieve cached response.
+               $cached_response = $this->get_cache( $cache_key );
+
+               if ( ! empty( $cached_response ) ) {
+                       $remote_url_response = $cached_response;
+               } else {
+                       $remote_url_response = $this->get_remote_url( $url );
+
+                       // Exit if we don't have a valid body or it's empty.
+                       if ( is_wp_error( $remote_url_response ) || empty( $remote_url_response ) ) {
+                               return $remote_url_response;
+                       }
+
+                       // Cache the valid response.
+                       $this->set_cache( $cache_key, $remote_url_response );
+               }
+
+               $html_head     = $this->get_document_head( $remote_url_response );
+               $meta_elements = $this->get_meta_with_content_elements( $html_head );
+
+               $data = $this->add_additional_fields_to_object(
+                       array(
+                               'title'       => $this->get_title( $html_head ),
+                               'icon'        => $this->get_icon( $html_head, $url ),
+                               'description' => $this->get_description( $meta_elements ),
+                               'image'       => $this->get_image( $meta_elements, $url ),
+                       ),
+                       $request
+               );
+
+               // Wrap the data in a response object.
+               $response = rest_ensure_response( $data );
+
+               /**
+                * Filters the URL data for the response.
+                *
+                * @param WP_REST_Response $response The response object.
+                * @param string           $url      The requested URL.
+                * @param WP_REST_Request  $request  Request object.
+                * @param array            $remote_url_response HTTP response body from the remote URL.
+                */
+               return apply_filters( 'rest_prepare_url_details', $response, $url, $request, $remote_url_response );
+       }
+
+       /**
+        * Checks whether a given request has permission to read remote urls.
+        *
+        * @since 5.9.0
+        *
+        * @return WP_Error|bool True if the request has access, or WP_Error object.
+        */
+       public function permissions_check() {
+               if ( current_user_can( 'edit_posts' ) ) {
+                       return true;
+               }
+
+               foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
+                       if ( current_user_can( $post_type->cap->edit_posts ) ) {
+                               return true;
+                       }
+               }
+
+               return new WP_Error(
+                       'rest_cannot_view_url_details',
+                       __( 'Sorry, you are not allowed to process remote urls.' ),
+                       array( 'status' => rest_authorization_required_code() )
+               );
+       }
+
+       /**
+        * Retrieves the document title from a remote URL.
+        *
+        * @since 5.9.0
+        *
+        * @param string $url The website url whose HTML we want to access.
+        * @return string|WP_Error The HTTP response from the remote URL, or an error.
+        */
+       private function get_remote_url( $url ) {
+
+               /*
+                * Provide a modified UA string to workaround web properties which block WordPress "Pingbacks".
+                * Why? The UA string used for pingback requests contains `WordPress/` which is very similar
+                * to that used as the default UA string by the WP HTTP API. Therefore requests from this
+                * REST endpoint are being unintentionally blocked as they are misidentified as pingback requests.
+                * By slightly modifying the UA string, but still retaining the "WordPress" identification (via "WP")
+                * we are able to work around this issue.
+                * Example UA string: `WP-URLDetails/5.9-alpha-51389 (+http://localhost:8888)`.
+               */
+               $modified_user_agent = 'WP-URLDetails/' . get_bloginfo( 'version' ) . ' (+' . get_bloginfo( 'url' ) . ')';
+
+               $args = array(
+                       'limit_response_size' => 150 * KB_IN_BYTES,
+                       'user-agent'          => $modified_user_agent,
+               );
+
+               /**
+                * Filters the HTTP request args for URL data retrieval.
+                *
+                * Can be used to adjust response size limit and other WP_Http::request args.
+                *
+                * @param array $args Arguments used for the HTTP request
+                * @param string $url The attempted URL.
+                */
+               $args = apply_filters( 'rest_url_details_http_request_args', $args, $url );
+
+               $response = wp_safe_remote_get( $url, $args );
+
+               if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) {
+                       // Not saving the error response to cache since the error might be temporary.
+                       return new WP_Error( 'no_response', __( 'URL not found. Response returned a non-200 status code for this URL.' ), array( 'status' => WP_Http::NOT_FOUND ) );
+               }
+
+               $remote_body = wp_remote_retrieve_body( $response );
+
+               if ( empty( $remote_body ) ) {
+                       return new WP_Error( 'no_content', __( 'Unable to retrieve body from response at this URL.' ), array( 'status' => WP_Http::NOT_FOUND ) );
+               }
+
+               return $remote_body;
+       }
+
+       /**
+        * Parses the `<title>` contents from the provided HTML.
+        *
+        * @since 5.9.0
+        *
+        * @param string $html The HTML from the remote website at URL.
+        * @return string The title tag contents on success, or an empty string.
+        */
+       private function get_title( $html ) {
+               $pattern = '#<title[^>]*>(.*?)<\s*/\s*title>#is';
+               preg_match( $pattern, $html, $match_title );
+
+               $title = ! empty( $match_title[1] ) && is_string( $match_title[1] ) ? trim( $match_title[1] ) : '';
+
+               if ( empty( $title ) ) {
+                       return '';
+               }
+
+               return $this->prepare_metadata_for_output( $title );
+       }
+
+       /**
+        * Parses the site icon from the provided HTML.
+        *
+        * @since 5.9.0
+        *
+        * @param string $html The HTML from the remote website at URL.
+        * @param string $url  The target website URL.
+        * @return string The icon URI on success, or an empty string.
+        */
+       private function get_icon( $html, $url ) {
+               // Grab the icon's link element.
+               $pattern = '#<link\s[^>]*rel=(?:[\"\']??)\s*(?:icon|shortcut icon|icon shortcut)\s*(?:[\"\']??)[^>]*\/?>#isU';
+               preg_match( $pattern, $html, $element );
+               $element = ! empty( $element[0] ) && is_string( $element[0] ) ? trim( $element[0] ) : '';
+               if ( empty( $element ) ) {
+                       return '';
+               }
+
+               // Get the icon's href value.
+               $pattern = '#href=([\"\']??)([^\" >]*?)\\1[^>]*#isU';
+               preg_match( $pattern, $element, $icon );
+               $icon = ! empty( $icon[2] ) && is_string( $icon[2] ) ? trim( $icon[2] ) : '';
+               if ( empty( $icon ) ) {
+                       return '';
+               }
+
+               // If the icon is a data URL, return it.
+               $parsed_icon = parse_url( $icon );
+               if ( isset( $parsed_icon['scheme'] ) && 'data' === $parsed_icon['scheme'] ) {
+                       return $icon;
+               }
+
+               // Attempt to convert relative URLs to absolute.
+               if ( ! is_string( $url ) || '' === $url ) {
+                       return $icon;
+               }
+               $parsed_url = parse_url( $url );
+               if ( isset( $parsed_url['scheme'] ) && isset( $parsed_url['host'] ) ) {
+                       $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
+                       $icon     = WP_Http::make_absolute_url( $icon, $root_url );
+               }
+
+               return $icon;
+       }
+
+       /**
+        * Parses the meta description from the provided HTML.
+        *
+        * @since 5.9.0
+        *
+        * @param array $meta_elements {
+        *     A multi-dimensional indexed array on success, or empty array.
+        *
+        *     @type string[] 0 Meta elements with a content attribute.
+        *     @type string[] 1 Content attribute's opening quotation mark.
+        *     @type string[] 2 Content attribute's value for each meta element.
+        * }
+        * @return string The meta description contents on success, or an empty string.
+        */
+       private function get_description( $meta_elements ) {
+               // Bail out if there are no meta elements.
+               if ( empty( $meta_elements[0] ) ) {
+                       return '';
+               }
+
+               $description = $this->get_metadata_from_meta_element( $meta_elements, 'name', '(?:description|og:description)' );
+
+               // Bail out if description not found.
+               if ( '' === $description ) {
+                       return '';
+               }
+
+               return $this->prepare_metadata_for_output( $description );
+       }
+
+       /**
+        * Parses the Open Graph Image from the provided HTML.
+        *
+        * See: https://ogp.me/.
+        *
+        * @since 5.9.0
+        *
+        * @param array  $meta_elements {
+        *     A multi-dimensional indexed array on success, or empty array.
+        *
+        *     @type string[] 0 Meta elements with a content attribute.
+        *     @type string[] 1 Content attribute's opening quotation mark.
+        *     @type string[] 2 Content attribute's value for each meta element.
+        * }
+        * @param string $url The target website URL.
+        * @return string The OG image on success, or empty string.
+        */
+       private function get_image( $meta_elements, $url ) {
+               $image = $this->get_metadata_from_meta_element( $meta_elements, 'property', '(?:og:image|og:image:url)' );
+
+               // Bail out if image not found.
+               if ( '' === $image ) {
+                       return '';
+               }
+
+               // Attempt to convert relative URLs to absolute.
+               $parsed_url = parse_url( $url );
+               if ( isset( $parsed_url['scheme'] ) && isset( $parsed_url['host'] ) ) {
+                       $root_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . '/';
+                       $image    = WP_Http::make_absolute_url( $image, $root_url );
+               }
+
+               return $image;
+       }
+
+       /**
+        * Prepares the metadata by:
+        *    - stripping all HTML tags and tag entities.
+        *    - converting non-tag entities into characters.
+        *
+        * @since 5.9.0
+        *
+        * @param string $metadata The metadata content to prepare.
+        * @return string The prepared metadata.
+        */
+       private function prepare_metadata_for_output( $metadata ) {
+               $metadata = html_entity_decode( $metadata, ENT_QUOTES, get_bloginfo( 'charset' ) );
+               $metadata = wp_strip_all_tags( $metadata );
+               return $metadata;
+       }
+
+       /**
+        * Utility function to build cache key for a given URL.
+        *
+        * @since 5.9.0
+        *
+        * @param string $url The URL for which to build a cache key.
+        * @return string The cache key.
+        */
+       private function build_cache_key_for_url( $url ) {
+               return 'g_url_details_response_' . md5( $url );
+       }
+
+       /**
+        * Utility function to retrieve a value from the cache at a given key.
+        *
+        * @since 5.9.0
+        *
+        * @param string $key The cache key.
+        * @return mixed The value from the cache.
+        */
+       private function get_cache( $key ) {
+               return get_transient( $key );
+       }
+
+       /**
+        * Utility function to cache a given data set at a given cache key.
+        *
+        * @since 5.9.0
+        *
+        * @param string $key  The cache key under which to store the value.
+        * @param string $data The data to be stored at the given cache key.
+        * @return bool True when transient set. False if fails.
+        */
+       private function set_cache( $key, $data = '' ) {
+               $ttl = HOUR_IN_SECONDS;
+
+               /**
+                * Filters the cache expiration.
+                *
+                * Can be used to adjust the time until expiration in seconds for the cache
+                * of the data retrieved for the given URL.
+                *
+                * @param int $ttl the time until cache expiration in seconds.
+                */
+               $cache_expiration = apply_filters( 'rest_url_details_cache_expiration', $ttl );
+
+               return set_transient( $key, $data, $cache_expiration );
+       }
+
+       /**
+        * Retrieves the `<head>` section.
+        *
+        * @since 5.9.0
+        *
+        * @param string $html The string of HTML to parse.
+        * @return string The `<head>..</head>` section on success, or original HTML.
+        */
+       private function get_document_head( $html ) {
+               $head_html = $html;
+
+               // Find the opening `<head>` tag.
+               $head_start = strpos( $html, '<head' );
+               if ( false === $head_start ) {
+                       // Didn't find it. Return the original HTML.
+                       return $html;
+               }
+
+               // Find the closing `</head>` tag.
+               $head_end = strpos( $head_html, '</head>' );
+               if ( false === $head_end ) {
+                       // Didn't find it. Find the opening `<body>` tag.
+                       $head_end = strpos( $head_html, '<body' );
+
+                       // Didn't find it. Return the original HTML.
+                       if ( false === $head_end ) {
+                               return $html;
+                       }
+               }
+
+               // Extract the HTML from opening tag to the closing tag. Then add the closing tag.
+               $head_html  = substr( $head_html, $head_start, $head_end );
+               $head_html .= '</head>';
+
+               return $head_html;
+       }
+
+       /**
+        * Gets all the `<meta>` elements that have a `content` attribute.
+        *
+        * @since 5.9.0
+        *
+        * @param string $html The string of HTML to be parsed.
+        * @return array {
+        *     A multi-dimensional indexed array on success, or empty array.
+        *
+        *     @type string[] 0 Meta elements with a content attribute.
+        *     @type string[] 1 Content attribute's opening quotation mark.
+        *     @type string[] 2 Content attribute's value for each meta element.
+        * }
+        */
+       private function get_meta_with_content_elements( $html ) {
+               /*
+                * Parse all meta elements with a content attribute.
+                *
+                * Why first search for the content attribute rather than directly searching for name=description element?
+                * tl;dr The content attribute's value will be truncated when it contains a > symbol.
+                *
+                * The content attribute's value (i.e. the description to get) can have HTML in it and be well-formed as
+                * it's a string to the browser. Imagine what happens when attempting to match for the name=description
+                * first. Hmm, if a > or /> symbol is in the content attribute's value, then it terminates the match
+                * as the element's closing symbol. But wait, it's in the content attribute and is not the end of the
+                * element. This is a limitation of using regex. It can't determine "wait a minute this is inside of quotation".
+                * If this happens, what gets matched is not the entire element or all of the content.
+                *
+                * Why not search for the name=description and then content="(.*)"?
+                * The attribute order could be opposite. Plus, additional attributes may exist including being between
+                * the name and content attributes.
+                *
+                * Why not lookahead?
+                * Lookahead is not constrained to stay within the element. The first <meta it finds may not include
+                * the name or content, but rather could be from a different element downstream.
+                */
+               $pattern = '#<meta\s' .
+
+                               /*
+                                * Alows for additional attributes before the content attribute.
+                                * Searches for anything other than > symbol.
+                                */
+                               '[^>]*' .
+
+                               /*
+                               * Find the content attribute. When found, capture its value (.*).
+                               *
+                               * Allows for (a) single or double quotes and (b) whitespace in the value.
+                               *
+                               * Why capture the opening quotation mark, i.e. (["\']), and then backreference,
+                               * i.e \1, for the closing quotation mark?
+                               * To ensure the closing quotation mark matches the opening one. Why? Attribute values
+                               * can contain quotation marks, such as an apostrophe in the content.
+                               */
+                               'content=(["\']??)(.*)\1' .
+
+                               /*
+                               * Allows for additional attributes after the content attribute.
+                               * Searches for anything other than > symbol.
+                               */
+                               '[^>]*' .
+
+                               /*
+                               * \/?> searches for the closing > symbol, which can be in either /> or > format.
+                               * # ends the pattern.
+                               */
+                               '\/?>#' .
+
+                               /*
+                               * These are the options:
+                               * - i : case insensitive
+                               * - s : allows newline characters for the . match (needed for multiline elements)
+                               * - U means non-greedy matching
+                               */
+                               'isU';
+
+               preg_match_all( $pattern, $html, $elements );
+
+               return $elements;
+       }
+
+       /**
+        * Gets the metadata from a target meta element.
+        *
+        * @since 5.9.0
+        *
+        * @param array  $meta_elements {
+        *     A multi-dimensional indexed array on success, or empty array.
+        *
+        *     @type string[] 0 Meta elements with a content attribute.
+        *     @type string[] 1 Content attribute's opening quotation mark.
+        *     @type string[] 2 Content attribute's value for each meta element.
+        * }
+        * @param string $attr Attribute that identifies the element with the target metadata.
+        * @param string $attr_value The attribute's value that identifies the element with the target metadata.
+        * @return string The metadata on success, or an empty string.
+        */
+       private function get_metadata_from_meta_element( $meta_elements, $attr, $attr_value ) {
+               // Bail out if there are no meta elements.
+               if ( empty( $meta_elements[0] ) ) {
+                       return '';
+               }
+
+               $metadata = '';
+               $pattern  = '#' .
+                               /*
+                                * Target this attribute and value to find the metadata element.
+                                *
+                                * Allows for (a) no, single, double quotes and (b) whitespace in the value.
+                                *
+                                * Why capture the opening quotation mark, i.e. (["\']), and then backreference,
+                                * i.e \1, for the closing quotation mark?
+                                * To ensure the closing quotation mark matches the opening one. Why? Attribute values
+                                * can contain quotation marks, such as an apostrophe in the content.
+                                */
+                               $attr . '=([\"\']??)\s*' . $attr_value . '\s*\1' .
+
+                               /*
+                                * These are the options:
+                                * - i : case insensitive
+                                * - s : allows newline characters for the . match (needed for multiline elements)
+                                * - U means non-greedy matching
+                                */
+                               '#isU';
+
+               // Find the metdata element.
+               foreach ( $meta_elements[0] as $index => $element ) {
+                       preg_match( $pattern, $element, $match );
+
+                       // This is not the metadata element. Skip it.
+                       if ( empty( $match ) ) {
+                               continue;
+                       }
+
+                       /*
+                        * Found the metadata element.
+                        * Get the metadata from its matching content array.
+                        */
+                       if ( isset( $meta_elements[2][ $index ] ) && is_string( $meta_elements[2][ $index ] ) ) {
+                               $metadata = trim( $meta_elements[2][ $index ] );
+                       }
+
+                       break;
+               }
+
+               return $metadata;
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-url-details-controller.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/rest-api.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api.php        2021-11-01 23:12:06 UTC (rev 51972)
+++ trunk/src/wp-includes/rest-api.php  2021-11-02 12:46:01 UTC (rev 51973)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -337,6 +337,10 @@
</span><span class="cx" style="display: block; padding: 0 10px">        $site_health = WP_Site_Health::get_instance();
</span><span class="cx" style="display: block; padding: 0 10px">        $controller  = new WP_REST_Site_Health_Controller( $site_health );
</span><span class="cx" style="display: block; padding: 0 10px">        $controller->register_routes();
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+       // URL Details.
+       $controller = new WP_REST_URL_Details_Controller();
+       $controller->register_routes();
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px"> /**
</span></span></pre></div>
<a id="trunksrcwpsettingsphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-settings.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-settings.php 2021-11-01 23:12:06 UTC (rev 51972)
+++ trunk/src/wp-settings.php   2021-11-02 12:46:01 UTC (rev 51973)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -276,6 +276,7 @@
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-widget-types-controller.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-widgets-controller.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-templates-controller.php';
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-url-details-controller.php';
</ins><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php';
</span></span></pre></div>
<a id="trunktestsphpunittestsrestapiwpRestUrlDetailsControllerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php                         (rev 0)
+++ trunk/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php   2021-11-02 12:46:01 UTC (rev 51973)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,1192 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * WP_REST_URL_Details_Controller tests.
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 5.9.0
+ */
+
+/**
+ * Tests for WP_REST_URL_Details_Controller.
+ *
+ * @since 5.9.0
+ *
+ * @covers WP_REST_URL_Details_Controller
+ *
+ * @group url-details
+ * @group restapi
+ */
+class Tests_REST_WpRestUrlDetailsController extends WP_Test_REST_Controller_Testcase {
+
+       /**
+        * Admin user ID.
+        *
+        * @since 5.9.0
+        *
+        * @var int
+        */
+       protected static $admin_id;
+
+       /**
+        * Subscriber user ID.
+        *
+        * @since 5.5.0
+        *
+        * @var int
+        */
+       protected static $subscriber_id;
+
+       /**
+        * The REST API route for the block renderer.
+        *
+        * @since 5.9.0
+        *
+        * @var string
+        */
+       const REQUEST_ROUTE = '/wp-block-editor/v1/url-details';
+
+       /**
+        * URL placeholder.
+        *
+        * @since 5.9.0
+        *
+        * @var string
+        */
+       const URL_PLACEHOLDER = 'https://placeholder-site.com';
+
+       /**
+        * Array of request args.
+        *
+        * @var array
+        */
+       protected $request_args = array();
+
+       /**
+        * Set up class test fixtures.
+        *
+        * @since 5.9.0
+        *
+        * @param WP_UnitTest_Factory $factory WordPress unit test factory.
+        */
+       public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
+               self::$admin_id      = $factory->user->create(
+                       array(
+                               'role' => 'administrator',
+                       )
+               );
+               self::$subscriber_id = $factory->user->create(
+                       array(
+                               'role' => 'subscriber',
+                       )
+               );
+       }
+
+       public static function wpTearDownAfterClass() {
+               self::delete_user( self::$admin_id );
+               self::delete_user( self::$subscriber_id );
+       }
+
+       public function set_up() {
+               parent::set_up();
+
+               add_filter( 'pre_http_request', array( $this, 'mock_success_request_to_remote_url' ), 10, 3 );
+
+               // Disables usage of cache during major of tests.
+               add_filter( 'pre_transient_' . $this->get_transient_name(), '__return_null' );
+       }
+
+       public function tear_down() {
+               $this->request_args = array();
+               parent::tear_down();
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::get_routes
+        *
+        * @ticket 54358
+        */
+       public function test_register_routes() {
+               $routes = rest_get_server()->get_routes();
+               $this->assertArrayHasKey( static::REQUEST_ROUTE, $routes );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_get_items() {
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               /*
+                * Note the data in the subset comes from the fixture HTML returned by
+                * the filter `pre_http_request` (see this class's `set_up` method).
+                */
+               $this->assertSame(
+                       array(
+                               'title'       => 'Example Website â€” - with encoded content.',
+                               'icon'        => 'https://placeholder-site.com/favicon.ico?querystringaddedfortesting',
+                               'description' => 'Example description text here. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore.',
+                               'image'       => 'https://placeholder-site.com/images/home/screen-themes.png?3',
+                       ),
+                       $data
+               );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::permissions_check
+        *
+        * @ticket 54358
+        */
+       public function test_get_items_fails_for_unauthenticated_user() {
+               wp_set_current_user( 0 );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertSame( WP_Http::UNAUTHORIZED, $response->get_status(), 'Response status is not ' . WP_Http::UNAUTHORIZED );
+
+               $this->assertSame( 'rest_cannot_view_url_details', $data['code'], 'Response "code" is not "rest_cannot_view_url_details"' );
+
+               $expected = 'you are not allowed to process remote urls';
+               $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain  "' . $expected . '"' );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::permissions_check
+        *
+        * @ticket 54358
+        */
+       public function test_get_items_fails_for_user_with_insufficient_permissions() {
+               wp_set_current_user( self::$subscriber_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertSame( WP_Http::FORBIDDEN, $response->get_status(), 'Response status is not ' . WP_Http::FORBIDDEN );
+
+               $this->assertSame( 'rest_cannot_view_url_details', $data['code'], 'Response "code" is not "rest_cannot_view_url_details"' );
+
+               $expected = 'you are not allowed to process remote urls';
+               $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' );
+       }
+
+       /**
+        * @dataProvider data_get_items_fails_for_invalid_url
+        *
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        *
+        * @param mixed $invalid_url Given invalid URL to test.
+        */
+       public function test_get_items_fails_for_invalid_url( $invalid_url ) {
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => $invalid_url,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status(), 'Response status is not ' . WP_Http::BAD_REQUEST );
+
+               $this->assertSame( 'rest_invalid_param', $data['code'], 'Response "code" is not "rest_invalid_param"' );
+
+               $expected = 'invalid parameter(s): url';
+               $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array
+        */
+       public function data_get_items_fails_for_invalid_url() {
+               return array(
+                       'empty string'   => array( '' ),
+                       'numeric'        => array( 1234456 ),
+                       'invalid scheme' => array( 'invalid.proto://wordpress.org' ),
+               );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_get_items_fails_for_url_which_returns_a_non_200_status_code() {
+               // Force HTTP request to remote site to fail.
+               remove_filter( 'pre_http_request', array( $this, 'mock_success_request_to_remote_url' ), 10 );
+               add_filter( 'pre_http_request', array( $this, 'mock_failed_request_to_remote_url' ), 10, 3 );
+
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER, // note: `pre_http_request` causes request to 404.
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertSame( 404, $response->get_status(), 'Response status is not 404' );
+
+               $this->assertSame( 'no_response', $data['code'], 'Response "code" is not "no_response"' );
+
+               $this->assertStringContainsString( 'not found', strtolower( $data['message'] ), 'Response "message" does not contain "not found"' );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_get_items_fails_for_url_which_returns_empty_body_for_success() {
+               // Force HTTP request to remote site to return an empty body in response.
+               remove_filter( 'pre_http_request', array( $this, 'mock_success_request_to_remote_url' ) );
+               add_filter( 'pre_http_request', array( $this, 'mock_request_to_remote_url_with_empty_body_response' ), 10, 3 );
+
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER, // note: `pre_http_request` causes request to 404.
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertSame( 404, $response->get_status(), 'Response status is not 404' );
+
+               $this->assertSame( 'no_content', $data['code'], 'Response "code" is not "no_content"' );
+
+               $expected = strtolower( 'Unable to retrieve body from response at this URL' );
+               $this->assertStringContainsString( $expected, strtolower( $data['message'] ), 'Response "message" does not contain "' . $expected . '"' );
+
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_can_filter_http_request_args_via_filter() {
+               wp_set_current_user( self::$admin_id );
+
+               add_filter(
+                       'rest_url_details_http_request_args',
+                       static function( $args, $url ) {
+                               return array_merge(
+                                       $args,
+                                       array(
+                                               'timeout' => 27, // modify default timeout.
+                                               'body'    => $url, // add new and allow to assert on $url arg passed.
+                                       )
+                               );
+                       },
+                       10,
+                       2
+               );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+
+               rest_get_server()->dispatch( $request );
+
+               // Check the args were filtered as expected.
+               $this->assertArrayHasKey( 'timeout', $this->request_args, 'Request args do not contain a "timeout" key' );
+               $this->assertArrayHasKey( 'limit_response_size', $this->request_args, 'Request args do not contain a "limit_response_size" key' );
+               $this->assertArrayHasKey( 'body', $this->request_args, 'Request args do not contain a "body" key' );
+               $this->assertSame( 27, $this->request_args['timeout'], 'Request args "timeout" is not 27' );
+               $this->assertSame( 153600, $this->request_args['limit_response_size'], 'Request args "limit_response_size" is not 153600' );
+               $this->assertSame( static::URL_PLACEHOLDER, $this->request_args['body'], 'Request args "body" is not "' . static::URL_PLACEHOLDER . '"' );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_will_return_from_cache_if_populated() {
+               $transient_name = $this->get_transient_name();
+               remove_filter( "pre_transient_{$transient_name}", '__return_null' );
+
+               // Force cache to return a known value as the remote URL http response body.
+               add_filter(
+                       "pre_transient_{$transient_name}",
+                       static function() {
+                               return '<html><head><title>This value from cache.</title></head><body></body></html>';
+                       }
+               );
+
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               // Data should be that from cache not from mocked network response.
+               $this->assertStringContainsString( 'This value from cache', $data['title'] );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_allows_filtering_data_retrieved_for_a_given_url() {
+               add_filter(
+                       'rest_prepare_url_details',
+                       static function( $response ) {
+
+                               $data = $response->get_data();
+
+                               $response->set_data(
+                                       array_merge(
+                                               $data,
+                                               array(
+                                                       'og_title' => 'This was manually added to the data via filter',
+                                               )
+                                       )
+                               );
+
+                               return $response;
+
+                       }
+               );
+
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               /*
+                * Instead of the default data retrieved we expect to see the modified
+                * data we provided via the filter.
+                */
+               $expected = 'Example Website â€” - with encoded content.';
+               $this->assertSame( $expected, $data['title'], 'Response "title" is not "' . $expected . '"' );
+               $expected = 'This was manually added to the data via filter';
+               $this->assertSame( $expected, $data['og_title'], 'Response "og_title" is not "' . $expected . '"' );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::parse_url_details
+        *
+        * @ticket 54358
+        */
+       public function test_allows_filtering_response() {
+               /*
+                * Filter the response to known set of values changing only
+                * based on whether the response came from the cache or not.
+                */
+               add_filter(
+                       'rest_prepare_url_details',
+                       static function( $response, $url ) {
+                               return new WP_REST_Response(
+                                       array(
+                                               'status'        => 418,
+                                               'response'      => "Response for URL $url altered via rest_prepare_url_details filter",
+                                               'body_response' => array(),
+                                       )
+                               );
+                       },
+                       10,
+                       3
+               );
+
+               wp_set_current_user( self::$admin_id );
+
+               $request = new WP_REST_Request( 'GET', static::REQUEST_ROUTE );
+               $request->set_query_params(
+                       array(
+                               'url' => static::URL_PLACEHOLDER,
+                       )
+               );
+               $response = rest_get_server()->dispatch( $request );
+
+               $data = $response->get_data();
+
+               $this->assertSame( 418, $data['status'], 'Response "status" is not 418' );
+
+               $expected = 'Response for URL https://placeholder-site.com altered via rest_prepare_url_details filter';
+               $this->assertSame( $expected, $data['response'], 'Response "response" is not "' . $expected . '"' );
+       }
+
+       /**
+        * @covers WP_REST_URL_Details_Controller::get_item_schema
+        *
+        * @ticket 54358
+        */
+       public function test_get_item_schema() {
+               wp_set_current_user( self::$admin_id );
+
+               $request  = new WP_REST_Request( 'OPTIONS', static::REQUEST_ROUTE );
+               $response = rest_get_server()->dispatch( $request );
+               $data     = $response->get_data();
+
+               $endpoint = $data['endpoints'][0];
+
+               $this->assertArrayHasKey( 'url', $endpoint['args'], 'Endpoint "args" does not contain a "url" key' );
+               $this->assertSame(
+                       array(
+                               'description' => 'The URL to process.',
+                               'type'        => 'string',
+                               'format'      => 'uri',
+                               'required'    => true,
+                       ),
+                       $endpoint['args']['url'],
+                       'Response endpoint "[args][url]" does not contain expected schema'
+               );
+       }
+
+       /**
+        * @dataProvider data_get_title
+        *
+        * @covers WP_REST_URL_Details_Controller::get_title
+        *
+        * @ticket 54358
+        *
+        * @param string $html     Given HTML string.
+        * @param string $expected Expected found title.
+        */
+       public function test_get_title( $html, $expected ) {
+               $controller = new WP_REST_URL_Details_Controller();
+               $method     = $this->get_reflective_method( 'get_title' );
+
+               $actual = $method->invoke(
+                       $controller,
+                       $this->wrap_html_in_doc( $html )
+               );
+               $this->assertSame( $expected, $actual );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array
+        */
+       public function data_get_title() {
+               return array(
+
+                       // Happy path for default.
+                       'default'                        => array(
+                               '<title>Testing &lt;title&gt;</title>',
+                               'Testing',
+                       ),
+                       'with attributes'                => array(
+                               '<title data-test-title-attr-one="test" data-test-title-attr-two="test2">Testing &lt;title&gt;</title>',
+                               'Testing',
+                       ),
+                       'with text whitespace'           => array(
+                               '<title data-test-title-attr-one="test" data-test-title-attr-two="test2">   Testing &lt;title&gt;     </title>',
+                               'Testing',
+                       ),
+                       'with whitespace in opening tag' => array(
+                               '<title >Testing &lt;title&gt;: with whitespace in opening tag</title>',
+                               'Testing : with whitespace in opening tag',
+                       ),
+                       'when whitepace in closing tag'  => array(
+                               '<title>Testing &lt;title&gt;: with whitespace in closing tag</ title>',
+                               'Testing : with whitespace in closing tag',
+                       ),
+                       'with other elements'            => array(
+                               '<meta name="viewport" content="width=device-width">
+                               <title>Testing &lt;title&gt;</title>
+                               <link rel="shortcut icon" href="https://wordpress.org/favicon.ico" />',
+                               'Testing',
+                       ),
+                       'multiline'                      => array(
+                               '<title>
+                                       Testing &lt;title&gt;
+                               </title>',
+                               'Testing',
+                       ),
+
+                       // Unhappy paths.
+                       'when opening tag is malformed'  => array(
+                               '< title>Testing &lt;title&gt;: when opening tag is invalid</title>',
+                               '',
+                       ),
+               );
+       }
+
+       /**
+        * @dataProvider data_get_icon
+        *
+        * @covers WP_REST_URL_Details_Controller::get_icon
+        *
+        * @ticket 54358
+        *
+        * @param string $html       Given HTML string.
+        * @param string $expected   Expected found icon.
+        * @param string $target_url Optional. Target URL. Default 'https://wordpress.org'.
+        */
+       public function test_get_icon( $html, $expected, $target_url = 'https://wordpress.org' ) {
+               $controller = new WP_REST_URL_Details_Controller();
+               $method     = $this->get_reflective_method( 'get_icon' );
+
+               $actual = $method->invoke(
+                       $controller,
+                       $this->wrap_html_in_doc( $html ),
+                       $target_url
+               );
+               $this->assertSame( $expected, $actual );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array
+        */
+       public function data_get_icon() {
+               return array(
+
+                       // Happy path for default.
+                       'default'                               => array(
+                               '<link rel="shortcut icon" href="https://wordpress.org/favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'default with no closing whitespace'    => array(
+                               '<link rel="shortcut icon" href="https://wordpress.org/favicon.ico"/>',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'default without self-closing'          => array(
+                               '<link rel="shortcut icon" href="https://wordpress.org/favicon.ico">',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'default with href first'               => array(
+                               '<link href="https://wordpress.org/favicon.ico" rel="shortcut icon" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'default with type last'                => array(
+                               '<link href="https://wordpress.org/favicon.png" rel="icon" type="image/png" />',
+                               'https://wordpress.org/favicon.png',
+                       ),
+                       'default with type first'               => array(
+                               '<link type="image/png" href="https://wordpress.org/favicon.png" rel="icon" />',
+                               'https://wordpress.org/favicon.png',
+                       ),
+                       'default with single quotes'            => array(
+                               '<link type="image/png" href=\'https://wordpress.org/favicon.png\' rel=\'icon\' />',
+                               'https://wordpress.org/favicon.png',
+                       ),
+
+                       // Happy paths.
+                       'with query string'                     => array(
+                               '<link rel="shortcut icon" href="https://wordpress.org/favicon.ico?somequerystring=foo&another=bar" />',
+                               'https://wordpress.org/favicon.ico?somequerystring=foo&another=bar',
+                       ),
+                       'with another link'                     => array(
+                               '<link rel="shortcut icon" href="https://wordpress.org/favicon.ico" /><link rel="canonical" href="https://example.com">',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'with multiple links'                   => array(
+                               '<link rel="manifest" href="/manifest.56b1cedc.json">
+                               <link rel="shortcut icon" href="https://wordpress.org/favicon.ico" />
+                               <link rel="canonical" href="https://example.com">',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'relative url'                          => array(
+                               '<link rel="shortcut icon" href="/favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'relative url no slash'                 => array(
+                               '<link rel="shortcut icon" href="favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'relative url with path'                => array(
+                               '<link rel="shortcut icon" href="favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                               'https://wordpress.org/my/path/here/',
+                       ),
+                       'rel reverse order'                     => array(
+                               '<link rel="icon shortcut" href="https://wordpress.org/favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'rel icon only'                         => array(
+                               '<link rel="icon" href="https://wordpress.org/favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'rel icon only with whitespace'         => array(
+                               '<link rel=" icon " href="https://wordpress.org/favicon.ico" />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'multiline attributes'                  => array(
+                               '<link
+                                       rel="icon"
+                                       href="https://wordpress.org/favicon.ico"
+                               />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'multiline attributes in reverse order' => array(
+                               '<link
+                                       rel="icon"
+                                       href="https://wordpress.org/favicon.ico"
+                               />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'multiline attributes with type'        => array(
+                               '<link
+                                       rel="icon"
+                                       href="https://wordpress.org/favicon.ico"
+                                       type="image/x-icon"
+                               />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'multiline with type first'             => array(
+                               '<link
+                                       type="image/x-icon"
+                                       rel="icon"
+                                       href="https://wordpress.org/favicon.ico"
+                               />',
+                               'https://wordpress.org/favicon.ico',
+                       ),
+                       'with data URL x-icon type'             => array(
+                               '<link rel="icon" href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=" type="image/x-icon" />',
+                               'data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=',
+                       ),
+                       'with data URL png type'                => array(
+                               '<link href="data:image/png;base64,iVBORw0KGgo=" rel="icon" type="image/png" />',
+                               'data:image/png;base64,iVBORw0KGgo=',
+                       ),
+
+                       // Unhappy paths.
+                       'empty rel'                             => array(
+                               '<link rel="" href="https://wordpress.org/favicon.ico" />',
+                               '',
+                       ),
+                       'empty href'                            => array(
+                               '<link rel="icon" href="" />',
+                               '',
+                       ),
+                       'no rel'                                => array(
+                               '<link href="https://wordpress.org/favicon.ico" />',
+                               '',
+                       ),
+                       'link to external stylesheet'           => array(
+                               '<link rel="stylesheet" href="https://example.com/assets/style.css" />',
+                               '',
+                               'https://example.com',
+                       ),
+                       'multiline with no href'                => array(
+                               '<link
+                                       rel="icon"
+                                       href=""
+                               />',
+                               '',
+                       ),
+                       'multiline with no rel'                 => array(
+                               '<link
+                                       rel=""
+                                       href="https://wordpress.org/favicon.ico"
+                               />',
+                               '',
+                       ),
+               );
+       }
+
+       /**
+        * @dataProvider data_get_description
+        *
+        * @covers WP_REST_URL_Details_Controller::get_description
+        *
+        * @ticket 54358
+        *
+        * @param string $html     Given HTML string.
+        * @param string $expected Expected found icon.
+        */
+       public function test_get_description( $html, $expected ) {
+               $controller = new WP_REST_URL_Details_Controller();
+
+               // Parse the meta elements from the given HTML.
+               $method        = $this->get_reflective_method( 'get_meta_with_content_elements' );
+               $meta_elements = $method->invoke(
+                       $controller,
+                       $this->wrap_html_in_doc( $html )
+               );
+
+               $method = $this->get_reflective_method( 'get_description' );
+               $actual = $method->invoke( $controller, $meta_elements );
+               $this->assertSame( $expected, $actual );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array
+        */
+       public function data_get_description() {
+               return array(
+
+                       // Happy paths.
+                       'default'                                    => array(
+                               '<meta name="description" content="This is a description.">',
+                               'This is a description.',
+                       ),
+                       'with whitespace'                            => array(
+                               '<meta  name=" description "   content=" This is a description.  "   >',
+                               'This is a description.',
+                       ),
+                       'with self-closing'                          => array(
+                               '<meta name="description" content="This is a description."/>',
+                               'This is a description.',
+                       ),
+                       'with self-closing and whitespace'           => array(
+                               '<meta  name=" description "   content=" This is a description.  "   />',
+                               'This is a description.',
+                       ),
+                       'with content first'                         => array(
+                               '<meta content="Content is first" name="description">',
+                               'Content is first',
+                       ),
+                       'with single quotes'                         => array(
+                               '<meta name=\'description\' content=\'with single quotes\'>',
+                               'with single quotes',
+                       ),
+                       'with another element'                       => array(
+                               '<meta name="description" content="This is a description."><meta name="viewport" content="width=device-width, initial-scale=1">',
+                               'This is a description.',
+                       ),
+                       'with multiple elements'                     => array(
+                               '<meta property="og:image" content="https://wordpress.org/images/myimage.jpg" />
+                               <link rel="stylesheet" href="https://example.com/assets/style.css" />
+                               <meta name="description" content="This is a description.">
+                               <meta name="viewport" content="width=device-width, initial-scale=1">',
+                               'This is a description.',
+                       ),
+                       'with other attributes'                      => array(
+                               '<meta first="first" name="description" third="third" content="description with other attributes" fifth="fifth">',
+                               'description with other attributes',
+                       ),
+                       'with open graph'                            => array(
+                               '<meta name="og:description" content="This is a OG description." />
+                               <meta name="description" content="This is a description.">',
+                               'This is a OG description.',
+                       ),
+
+                       // Happy paths with multiline attributes.
+                       'with multiline attributes'                  => array(
+                               '<meta
+                                       name="description"
+                                       content="with multiline attributes"
+                               >',
+                               'with multiline attributes',
+                       ),
+                       'with multiline attributes in reverse order' => array(
+                               '<meta
+                                       content="with multiline attributes in reverse order"
+                                       name="description"
+                               >',
+                               'with multiline attributes in reverse order',
+                       ),
+                       'with multiline attributes and another element' => array(
+                               '<meta
+                                       name="description"
+                                       content="with multiline attributes"
+                               >
+                               <meta name="viewport" content="width=device-width, initial-scale=1">',
+                               'with multiline attributes',
+                       ),
+                       'with multiline and other attributes'        => array(
+                               '<meta
+                                       first="first"
+                                       name="description"
+                                       third="third"
+                                       content="description with multiline and other attributes"
+                                       fifth="fifth"
+                               >',
+                               'description with multiline and other attributes',
+                       ),
+
+                       // Happy paths with HTML tags or entities in the description.
+                       'with HTML tags'                             => array(
+                               '<meta name="description" content="<strong>Description</strong>: has <em>HTML</em> tags">',
+                               'Description: has HTML tags',
+                       ),
+                       'with content first and HTML tags'           => array(
+                               '<meta content="<strong>Description</strong>: has <em>HTML</em> tags" name="description">',
+                               'Description: has HTML tags',
+                       ),
+                       'with HTML tags and other attributes'        => array(
+                               '<meta first="first" name="description" third="third" content="<strong>Description</strong>: has <em>HTML</em> tags" fifth="fifth>',
+                               'Description: has HTML tags',
+                       ),
+                       'with HTML entities'                         => array(
+                               '<meta name="description" content="The &lt;strong&gt;description&lt;/strong&gt; meta &amp; its attribute value"',
+                               'The description meta & its attribute value',
+                       ),
+
+                       // Unhappy paths.
+                       'with empty content'                         => array(
+                               '<meta name="description" content="">',
+                               '',
+                       ),
+                       'with empty name'                            => array(
+                               '<meta name="" content="name is empty">',
+                               '',
+                       ),
+                       'without a name attribute'                   => array(
+                               '<meta content="without a name attribute">',
+                               '',
+                       ),
+                       'without a content attribute'                => array(
+                               '<meta name="description">',
+                               '',
+                       ),
+               );
+       }
+
+       /**
+        * @dataProvider data_get_image
+        *
+        * @covers WP_REST_URL_Details_Controller::get_image
+        *
+        * @ticket 54358
+        *
+        * @param string $html       Given HTML string.
+        * @param string $expected   Expected found image.
+        * @param string $target_url Optional. Target URL. Default 'https://wordpress.org'.
+        */
+       public function test_get_image( $html, $expected, $target_url = 'https://wordpress.org' ) {
+               $controller = new WP_REST_URL_Details_Controller();
+
+               // Parse the meta elements from the given HTML.
+               $method        = $this->get_reflective_method( 'get_meta_with_content_elements' );
+               $meta_elements = $method->invoke(
+                       $controller,
+                       $this->wrap_html_in_doc( $html )
+               );
+
+               $method = $this->get_reflective_method( 'get_image' );
+               $actual = $method->invoke( $controller, $meta_elements, $target_url );
+               $this->assertSame( $expected, $actual );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array
+        */
+       public function data_get_image() {
+               return array(
+
+                       // Happy paths.
+                       'default'                                      => array(
+                               '<meta property="og:image" content="https://wordpress.org/images/myimage.jpg">',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with whitespace'                              => array(
+                               '<meta  property=" og:image "   content="  https://wordpress.org/images/myimage.jpg "  >',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with self-closing'                            => array(
+                               '<meta property="og:image" content="https://wordpress.org/images/myimage.jpg"/>',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with self-closing and whitespace'             => array(
+                               '<meta  property=" og:image "   content="  https://wordpress.org/images/myimage.jpg "  />',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with single quotes'                           => array(
+                               "<meta property='og:image' content='https://wordpress.org/images/myimage.jpg'>",
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'without quotes'                               => array(
+                               '<meta property=og:image content="https://wordpress.org/images/myimage.jpg">',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with url modifier'                            => array(
+                               '<meta property="og:image:url" content="https://wordpress.org/images/url-modifier.jpg" />
+                               <meta property="og:image" content="https://wordpress.org/images/myimage.jpg">',
+                               'https://wordpress.org/images/url-modifier.jpg',
+                       ),
+                       'with query string'                            => array(
+                               '<meta property="og:image" content="https://wordpress.org/images/withquerystring.jpg?foo=bar&bar=foo" />',
+                               'https://wordpress.org/images/withquerystring.jpg?foo=bar&bar=foo',
+                       ),
+
+                       // Happy paths with changing attributes order or adding attributes.
+                       'with content first'                           => array(
+                               '<meta content="https://wordpress.org/images/myimage.jpg" property="og:image">',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with other attributes'                        => array(
+                               '<meta first="first" property="og:image" third="third" content="https://wordpress.org/images/myimage.jpg" fifth="fifth">',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with other og meta'                           => array(
+                               '<meta property="og:image:height" content="720" />
+                               <meta property="og:image:alt" content="Ignore this please" />
+                               <meta property="og:image" content="https://wordpress.org/images/myimage.jpg" />
+                               <link rel="stylesheet" href="https://example.com/assets/style.css" />',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+
+                       // Happy paths with relative url.
+                       'with relative url'                            => array(
+                               '<meta property="og:image" content="/images/myimage.jpg" />',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with relative url without starting slash'     => array(
+                               '<meta property="og:image" content="images/myimage.jpg" />',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with relative url and path'                   => array(
+                               '<meta property="og:image" content="images/myimage.jpg" />',
+                               'https://wordpress.org/images/myimage.jpg',
+                               'https://wordpress.org/my/path/here/',
+                       ),
+
+                       // Happy paths with multiline attributes.
+                       'with multiline attributes'                    => array(
+                               '<meta
+                                       property="og:image"
+                                       content="https://wordpress.org/images/myimage.jpg"
+                               >',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with multiline attributes in reverse order'   => array(
+                               '<meta
+                                       content="https://wordpress.org/images/myimage.jpg"
+                                       property="og:image"
+                               >',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with multiline attributes and other elements' => array(
+                               '<meta
+                                       property="og:image:height"
+                                       content="720"
+                               />
+                               <meta
+                                       property="og:image:alt"
+                                       content="Ignore this please"
+                               />
+                               <meta
+                                       property="og:image"
+                                       content="https://wordpress.org/images/myimage.jpg"
+                               >
+                               <link rel="stylesheet" href="https://example.com/assets/style.css" />',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+                       'with multiline and other attributes'          => array(
+                               '<meta
+                                       first="first"
+                                       property="og:image:url"
+                                       third="third"
+                                       content="https://wordpress.org/images/myimage.jpg"
+                                       fifth="fifth"
+                               >',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+
+                       // Happy paths with HTML tags in the content.
+                       'with other og meta'                           => array(
+                               '<meta property="og:image:height" content="720" />
+                               <meta property="og:image:alt" content="<em>ignore this please</em>" />
+                               <meta property="og:image" content="https://wordpress.org/images/myimage.jpg" />
+                               <link rel="stylesheet" href="https://example.com/assets/style.css" />',
+                               'https://wordpress.org/images/myimage.jpg',
+                       ),
+
+                       // Unhappy paths.
+                       'with empty content'                           => array(
+                               '<meta property="og:image" content="">',
+                               '',
+                       ),
+                       'without a property attribute'                 => array(
+                               '<meta content="https://wordpress.org/images/myimage.jpg">',
+                               '',
+                       ),
+                       'without a content attribute empty property'   => array(
+                               '<meta property="og:image" href="https://wordpress.org/images/myimage.jpg">',
+                               '',
+                       ),
+               );
+       }
+
+       public function test_context_param() {
+               $this->markTestSkipped( 'Controller does not use context_param.' );
+       }
+
+       public function test_get_item() {
+               $this->markTestSkipped( 'Controller does not have get_item route.' );
+       }
+
+       public function test_create_item() {
+               $this->markTestSkipped( 'Controller does not have create_item route.' );
+       }
+
+       public function test_update_item() {
+               $this->markTestSkipped( 'Controller does not have update_item route.' );
+       }
+
+       public function test_delete_item() {
+               $this->markTestSkipped( 'Controller does not have delete_item route.' );
+       }
+
+       public function test_prepare_item() {
+               $this->markTestSkipped( 'Controller does not have prepare_item route.' );
+       }
+
+       /**
+        * Mocks the HTTP response for the `wp_safe_remote_get()` which
+        * would otherwise make a call to a real website.
+        *
+        * @return array faux/mocked response.
+        */
+       public function mock_success_request_to_remote_url( $response, $args ) {
+               return $this->mock_request_to_remote_url( 'success', $args );
+       }
+
+       public function mock_failed_request_to_remote_url( $response, $args ) {
+               return $this->mock_request_to_remote_url( 'failure', $args );
+       }
+
+       public function mock_request_to_remote_url_with_empty_body_response( $response, $args ) {
+               return $this->mock_request_to_remote_url( 'empty_body', $args );
+       }
+
+       private function mock_request_to_remote_url( $result_type, $args ) {
+               $this->request_args = $args;
+
+               $types = array(
+                       'success',
+                       'failure',
+                       'empty_body',
+               );
+
+               // Default to success.
+               if ( ! in_array( $result_type, $types, true ) ) {
+                       $result_type = $types[0];
+               }
+
+               // Both should return 200 for the HTTP response.
+               $should_200 = 'success' === $result_type || 'empty_body' === $result_type;
+
+               return array(
+                       'headers'     => array(),
+                       'cookies'     => array(),
+                       'filename'    => null,
+                       'response'    => array( 'code' => ( $should_200 ? 200 : 404 ) ),
+                       'status_code' => $should_200 ? 200 : 404,
+                       'success'     => $should_200 ? 1 : 0,
+                       'body'        => 'success' === $result_type ? $this->get_example_website() : '',
+               );
+       }
+
+       private function get_example_website() {
+               return '
+                       <!DOCTYPE html>
+                       <html xmlns="http://www.w3.org/1999/xhtml" dir="ltr" lang="en-US">
+                       <head>
+                       <meta charset="utf-8" />
+                       <title data-test-title-attr="test">Example Website &mdash; - with encoded content.</title>
+
+                       <link rel="shortcut icon" href="/favicon.ico?querystringaddedfortesting" type="image/x-icon" />
+
+                       <link rel="canonical" href="https://example.com">
+
+                       <meta name="description" content="Example description text here. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore." />
+
+                       <!-- Open Graph Tags -->
+                       <meta property="og:type" content="website" />
+                       <meta property="og:title" content="Example Website" />
+                       <meta property="og:url" content="https://example.com" />
+                       <meta property="og:site_name" content="Example Website" />
+                       <meta property="og:image:alt" content="Attempt to break image parsing" />
+                       <meta property="og:image" content="/images/home/screen-themes.png?3" />
+
+                       </head>
+                       <body>
+                               <h1>Example Website</h1>
+                           <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+                       </body>
+                       </html>';
+       }
+
+       private function wrap_html_in_doc( $html, $with_body = false ) {
+               $doc = '<!DOCTYPE html>
+                               <html xmlns="http://www.w3.org/1999/xhtml" dir="ltr" lang="en-US">
+                               <head>
+                               <meta charset="utf-8" />' . $html . "\n" . '</head>';
+
+               if ( $with_body ) {
+                       $doc .= '
+                               <body>
+                                       <h1>Example Website</h1>
+                                       <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+                               </body>
+                       </html>';
+               }
+
+               return $doc;
+       }
+
+       /**
+        * Gets the transient name.
+        *
+        * @return string
+        */
+       private function get_transient_name() {
+               return 'g_url_details_response_' . md5( static::URL_PLACEHOLDER );
+       }
+
+       /**
+        * Get reflective access to a private/protected method on
+        * the WP_REST_URL_Details_Controller class.
+        *
+        * @param string $method_name Method name for which to gain access.
+        * @return ReflectionMethod
+        * @throws ReflectionException Throws an exception if method does not exist.
+        */
+       protected function get_reflective_method( $method_name ) {
+               $class  = new ReflectionClass( WP_REST_URL_Details_Controller::class );
+               $method = $class->getMethod( $method_name );
+               $method->setAccessible( true );
+               return $method;
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsqunitfixtureswpapigeneratedjs"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/qunit/fixtures/wp-api-generated.js</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/qunit/fixtures/wp-api-generated.js    2021-11-01 23:12:06 UTC (rev 51972)
+++ trunk/tests/qunit/fixtures/wp-api-generated.js      2021-11-02 12:46:01 UTC (rev 51973)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -15,7 +15,8 @@
</span><span class="cx" style="display: block; padding: 0 10px">     "namespaces": [
</span><span class="cx" style="display: block; padding: 0 10px">         "oembed/1.0",
</span><span class="cx" style="display: block; padding: 0 10px">         "wp/v2",
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-        "wp-site-health/v1"
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+        "wp-site-health/v1",
+        "wp-block-editor/v1"
</ins><span class="cx" style="display: block; padding: 0 10px">     ],
</span><span class="cx" style="display: block; padding: 0 10px">     "authentication": [],
</span><span class="cx" style="display: block; padding: 0 10px">     "routes": {
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -7538,6 +7539,64 @@
</span><span class="cx" style="display: block; padding: 0 10px">                     }
</span><span class="cx" style="display: block; padding: 0 10px">                 ]
</span><span class="cx" style="display: block; padding: 0 10px">             }
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+        },
+        "/wp-block-editor/v1": {
+            "namespace": "wp-block-editor/v1",
+            "methods": [
+                "GET"
+            ],
+            "endpoints": [
+                {
+                    "methods": [
+                        "GET"
+                    ],
+                    "args": {
+                        "namespace": {
+                            "default": "wp-block-editor/v1",
+                            "required": false
+                        },
+                        "context": {
+                            "default": "view",
+                            "required": false
+                        }
+                    }
+                }
+            ],
+            "_links": {
+                "self": [
+                    {
+                        "href": "http://example.org/index.php?rest_route=/wp-block-editor/v1"
+                    }
+                ]
+            }
+        },
+        "/wp-block-editor/v1/url-details": {
+            "namespace": "wp-block-editor/v1",
+            "methods": [
+                "GET"
+            ],
+            "endpoints": [
+                {
+                    "methods": [
+                        "GET"
+                    ],
+                    "args": {
+                        "url": {
+                            "description": "The URL to process.",
+                            "type": "string",
+                            "format": "uri",
+                            "required": true
+                        }
+                    }
+                }
+            ],
+            "_links": {
+                "self": [
+                    {
+                        "href": "http://example.org/index.php?rest_route=/wp-block-editor/v1/url-details"
+                    }
+                ]
+            }
</ins><span class="cx" style="display: block; padding: 0 10px">         }
</span><span class="cx" style="display: block; padding: 0 10px">     },
</span><span class="cx" style="display: block; padding: 0 10px">     "site_logo": false
</span></span></pre>
</div>
</div>

</body>
</html>