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( '|<title>([^<]*?)|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 <span aria-hidden={ ! isURL } className="block-editor-link-control__search-item-info"> { ! isURL && ( safeDecodeURI( suggestion.url ) || '' ) } { isURL && ( - __( 'Press ENTER to add this link' ) + `${ safeDecodeURI( suggestion.url ) } - ${ __( 'press ENTER to add this link' ) }` ) } </span> </span> 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, }; }