Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add URL preview to Link UI #19387

Closed
wants to merge 12 commits into from
122 changes: 122 additions & 0 deletions lib/class-wp-rest-url-details-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
/**
*
*
* @package gutenberg
* @since 5.?.0
*/

/**
* Controller which provides REST endpoint for retrieving information
* from a remote site's HTML response.
*
* @since 5.?.0
*
* @see WP_REST_Controller
*/
class WP_REST_URL_Details_Controller extends WP_REST_Controller {

/**
* Constructs the controller.
*
* @access public
*/
public function __construct() {
$this->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 <title> 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>([^<]*?)</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;
}
}
5 changes: 5 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
11 changes: 11 additions & 0 deletions lib/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
45 changes: 32 additions & 13 deletions packages/block-editor/src/components/link-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -112,7 +112,7 @@ function LinkControl( {
setInputValue( '' );
};

const handleDirectEntry = ( value ) => {
const handleDirectEntry = async ( value, { fetchUrlInfo = true } = {} ) => {
let type = 'URL';

const protocol = getProtocol( value ) || '';
Expand All @@ -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 `<title>` 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.
Expand Down Expand Up @@ -264,6 +282,7 @@ export default compose(
const { getSettings } = select( 'core/block-editor' );
return {
fetchSearchSuggestions: getSettings().__experimentalFetchLinkSuggestions,
fetchRemoteURLTitle: getSettings().__experimentalFetchRemoteURLTitle,
};
} )
)( LinkControl );
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
15 changes: 14 additions & 1 deletion packages/editor/src/components/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -118,6 +130,7 @@ class EditorProvider extends Component {
__experimentalFetchReusableBlocks,
__experimentalFetchLinkSuggestions: fetchLinkSuggestions,
__experimentalCanUserUseUnfilteredHTML: canUserUseUnfilteredHTML,
__experimentalFetchRemoteURLTitle: fetchRemoteURLTitle,
};
}

Expand Down