diff --git a/lib/class-wp-rest-url-details-controller.php b/lib/class-wp-rest-url-details-controller.php
new file mode 100644
index 00000000000000..158e9e6c5adf22
--- /dev/null
+++ b/lib/class-wp-rest-url-details-controller.php
@@ -0,0 +1,122 @@
+namespace = '__experimental';
+ $this->rest_base = 'url-details';
+ }
+
+ /**
+ * Registers the necessary REST API routes.
+ *
+ * @access public
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/title',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_title' ),
+ 'args' => array(
+ 'url' => array(
+ 'validate_callback' => 'wp_http_validate_url',
+ 'sanitize_callback' => 'esc_url_raw',
+ ),
+ ),
+ 'permission_callback' => array( $this, 'get_remote_url_permissions_check' ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Retrieves the contents of the
tag from the HTML
+ * response.
+ *
+ * @access public
+ * @param WP_REST_REQUEST $request Full details about the request.
+ * @return String|WP_Error The title text or an error.
+ */
+ public function get_title( $request ) {
+ $url = $request->get_param( 'url' );
+ $title = $this->get_remote_url_title( $url );
+
+ return rest_ensure_response( $title );
+ }
+
+ /**
+ * Checks whether a given request has permission to read remote urls.
+ *
+ * @return WP_Error|bool True if the request has access, WP_Error object otherwise.
+ */
+ public function get_remote_url_permissions_check() {
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error(
+ 'rest_user_cannot_view',
+ __( 'Sorry, you are not allowed to access remote urls.', 'gutenberg' )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieves the document title from a remote URL.
+ *
+ * @param string $url The website url whose HTML we want to access.
+ * @return string|WP_Error The URL's document title on success, WP_Error on failure.
+ */
+ private function get_remote_url_title( $url ) {
+ // Transient per URL.
+ $cache_key = 'g_url_details_response_' . hash( 'crc32b', $url );
+
+ // Attempt to retrieve cached response.
+ $title = get_transient( $cache_key );
+
+ if ( empty( $title ) ) {
+ $request = wp_safe_remote_get( $url, array(
+ 'timeout' => 10,
+ 'limit_response_size' => 153600, // 150 KB.
+ ) );
+ $remote_source = wp_remote_retrieve_body( $request );
+
+ if ( ! $remote_source ) {
+ return new WP_Error( 'no_response', __( 'The source URL does not exist.', 'gutenberg' ), array( 'status' => 404 ) );
+ }
+
+ preg_match( '|([^<]*?)|is', $remote_source, $match_title );
+ $title = isset( $match_title[1] ) ? trim( $match_title[1] ) : '';
+
+ if ( empty( $title ) ) {
+ return new WP_Error( 'no_title', __( 'No document title at remote url.', 'gutenberg' ), array( 'status' => 404 ) );
+ }
+
+ // Only cache valid responses.
+ set_transient( $cache_key, $title, HOUR_IN_SECONDS );
+ }
+
+ return $title;
+ }
+}
diff --git a/lib/load.php b/lib/load.php
index 7076be29f63127..e3ee9b3a894d69 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -43,6 +43,11 @@ function gutenberg_is_experiment_enabled( $name ) {
* End: Include for phase 2
*/
+ if ( ! class_exists( 'WP_REST_URL_Details_Controller' ) ) {
+ require dirname( __FILE__ ) . '/class-wp-rest-url-details-controller.php';
+ }
+
+
require dirname( __FILE__ ) . '/rest-api.php';
}
diff --git a/lib/rest-api.php b/lib/rest-api.php
index e0b6e7be592e7b..f1b97b4bf86969 100644
--- a/lib/rest-api.php
+++ b/lib/rest-api.php
@@ -54,6 +54,17 @@ function gutenberg_filter_oembed_result( $response, $handler, $request ) {
+/**
+ * Registers the REST API routes for URL Details.
+ *
+ * @since 5.0.0
+ */
+function gutenberg_register_url_details_routes() {
+ $url_details_controller = new WP_REST_URL_Details_Controller();
+ $url_details_controller->register_routes();
+}
+add_action( 'rest_api_init', 'gutenberg_register_url_details_routes' );
+
/**
* Start: Include for phase 2
*/
diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js
index 18ae9da7c5401b..fcabde566ccc41 100644
--- a/packages/block-editor/src/components/link-control/index.js
+++ b/packages/block-editor/src/components/link-control/index.js
@@ -40,13 +40,13 @@ import LinkControlSearchItem from './search-item';
import LinkControlSearchInput from './search-input';
const MODE_EDIT = 'edit';
-// const MODE_SHOW = 'show';
function LinkControl( {
className,
currentLink,
currentSettings,
fetchSearchSuggestions,
+ fetchRemoteURLTitle,
instanceId,
onClose = noop,
onChangeMode = noop,
@@ -112,7 +112,7 @@ function LinkControl( {
setInputValue( '' );
};
- const handleDirectEntry = ( value ) => {
+ const handleDirectEntry = async ( value, { fetchUrlInfo = true } = {} ) => {
let type = 'URL';
const protocol = getProtocol( value ) || '';
@@ -129,24 +129,42 @@ function LinkControl( {
type = 'internal';
}
- return Promise.resolve(
- [ {
- id: '-1',
- title: value,
- url: type === 'URL' ? prependHTTP( value ) : value,
- type,
- } ]
- );
+ const defaultResponse = {
+ id: '-1',
+ title: value,
+ url: type === 'URL' ? prependHTTP( value ) : value,
+ type,
+ };
+
+ // If it's a URL then request the `` tag
+ // Todo:
+ // * avoid invalid requests for incomplete URLS
+ // * avoid concurrent requests - cancel existing AJAX requests if already pending
+ if ( fetchUrlInfo && type === 'URL' && isURL( prependHTTP( value ) ) && value.length > 3 ) {
+ try {
+ const urlTitle = await fetchRemoteURLTitle( value );
+ return [ {
+ ...defaultResponse,
+ title: urlTitle || value,
+ } ];
+ } catch ( error ) {
+ return [ defaultResponse ];
+ }
+ }
+
+ return [ defaultResponse ];
};
const handleEntitySearch = async ( value ) => {
+ const couldBeURL = ! value.includes( ' ' );
+
const results = await Promise.all( [
fetchSearchSuggestions( value ),
- handleDirectEntry( value ),
+ handleDirectEntry( value, {
+ fetchUrlInfo: couldBeURL,
+ } ),
] );
- const couldBeURL = ! value.includes( ' ' );
-
// If it's potentially a URL search then concat on a URL search suggestion
// just for good measure. That way once the actual results run out we always
// have a URL option to fallback on.
@@ -264,6 +282,7 @@ export default compose(
const { getSettings } = select( 'core/block-editor' );
return {
fetchSearchSuggestions: getSettings().__experimentalFetchLinkSuggestions,
+ fetchRemoteURLTitle: getSettings().__experimentalFetchRemoteURLTitle,
};
} )
)( LinkControl );
diff --git a/packages/block-editor/src/components/link-control/search-item.js b/packages/block-editor/src/components/link-control/search-item.js
index 7b086860d30560..d28992fd1a645e 100644
--- a/packages/block-editor/src/components/link-control/search-item.js
+++ b/packages/block-editor/src/components/link-control/search-item.js
@@ -36,7 +36,7 @@ export const LinkControlSearchItem = ( { itemProps, suggestion, isSelected = fal
{ ! isURL && ( safeDecodeURI( suggestion.url ) || '' ) }
{ isURL && (
- __( 'Press ENTER to add this link' )
+ `${ safeDecodeURI( suggestion.url ) } - ${ __( 'press ENTER to add this link' ) }`
) }
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 1d3714e5c09885..df4e395b3196ac 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -14,7 +14,7 @@ import { __ } from '@wordpress/i18n';
import { EntityProvider } from '@wordpress/core-data';
import { BlockEditorProvider, transformStyles } from '@wordpress/block-editor';
import apiFetch from '@wordpress/api-fetch';
-import { addQueryArgs } from '@wordpress/url';
+import { addQueryArgs, prependHTTP } from '@wordpress/url';
import { decodeEntities } from '@wordpress/html-entities';
/**
@@ -42,6 +42,18 @@ const fetchLinkSuggestions = async ( search ) => {
} ) );
};
+const fetchRemoteURLTitle = async ( url ) => {
+ const endpoint = '/__experimental/url-details/title';
+
+ const args = {
+ url: prependHTTP( url ),
+ };
+
+ return apiFetch( {
+ path: addQueryArgs( endpoint, args ),
+ } );
+};
+
class EditorProvider extends Component {
constructor( props ) {
super( ...arguments );
@@ -118,6 +130,7 @@ class EditorProvider extends Component {
__experimentalFetchReusableBlocks,
__experimentalFetchLinkSuggestions: fetchLinkSuggestions,
__experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML,
+ __experimentalFetchRemoteURLTitle: fetchRemoteURLTitle,
};
}