diff --git a/packages/block-editor/src/components/link-control/is-url-like.js b/packages/block-editor/src/components/link-control/is-url-like.js index 3021ace38a26e..58861b71b837b 100644 --- a/packages/block-editor/src/components/link-control/is-url-like.js +++ b/packages/block-editor/src/components/link-control/is-url-like.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { isURL } from '@wordpress/url'; +import { getProtocol, isValidProtocol, isValidFragment } from '@wordpress/url'; /** * Determines whether a given value could be a URL. Note this does not @@ -15,6 +15,43 @@ import { isURL } from '@wordpress/url'; * @return {boolean} whether or not the value is potentially a URL. */ export default function isURLLike( val ) { - const isInternal = val?.startsWith( '#' ); - return isURL( val ) || ( val && val.includes( 'www.' ) ) || isInternal; + const hasSpaces = val.includes( ' ' ); + + if ( hasSpaces ) { + return false; + } + + const protocol = getProtocol( val ); + const protocolIsValid = isValidProtocol( protocol ); + + const mayBeTLD = hasPossibleTLD( val ); + + const isWWW = val?.startsWith( 'www.' ); + + const isInternal = val?.startsWith( '#' ) && isValidFragment( val ); + + return protocolIsValid || isWWW || isInternal || mayBeTLD; +} + +/** + * Checks if a given URL has a valid Top-Level Domain (TLD). + * + * @param {string} url - The URL to check. + * @param {number} maxLength - The maximum length of the TLD. + * @return {boolean} Returns true if the URL has a valid TLD, false otherwise. + */ +function hasPossibleTLD( url, maxLength = 6 ) { + // Clean the URL by removing anything after the first occurrence of "?" or "#". + const cleanedURL = url.split( /[?#]/ )[ 0 ]; + + // Regular expression explanation: + // - (?<=\S) : Positive lookbehind assertion to ensure there is at least one non-whitespace character before the TLD + // - \. : Matches a literal dot (.) + // - [a-zA-Z_]{2,maxLength} : Matches 2 to maxLength letters or underscores, representing the TLD + // - (?:\/|$) : Non-capturing group that matches either a forward slash (/) or the end of the string + const regex = new RegExp( + `(?<=\\S)\\.(?:[a-zA-Z_]{2,${ maxLength }})(?:\\/|$)` + ); + + return regex.test( cleanedURL ); } diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index 55aab3816b27e..a0cf1cd1d23e7 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -196,8 +196,7 @@ describe( 'Basic rendering', () => { within( resultsList ).getAllByRole( 'option' ); expect( searchResultElements ).toHaveLength( - // The fauxEntitySuggestions length plus the 'Press ENTER to add this link' button. - fauxEntitySuggestions.length + 1 + fauxEntitySuggestions.length ); // Step down into the search results, highlighting the first result item. @@ -440,44 +439,87 @@ describe( 'Searching for a link', () => { expect( screen.queryByRole( 'presentation' ) ).not.toBeInTheDocument(); } ); - it( 'should display only search suggestions when current input value is not URL-like', async () => { - const user = userEvent.setup(); - const searchTerm = 'Hello world'; - const firstSuggestion = fauxEntitySuggestions[ 0 ]; + it.each( [ 'With spaces', 'Uppercase', 'lowercase' ] )( + 'should display only search suggestions (and not URL result type) when current input value (e.g. %s) is not URL-like', + async ( searchTerm ) => { + const user = userEvent.setup(); + const firstSuggestion = fauxEntitySuggestions[ 0 ]; - render( ); + render( ); - // Search Input UI. - const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + // Search Input UI. + const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); - // Simulate searching for a term. - await user.type( searchInput, searchTerm ); + // Simulate searching for a term. + await user.type( searchInput, searchTerm ); - const searchResultElements = within( - await screen.findByRole( 'listbox', { - name: /Search results for.*/, - } ) - ).getAllByRole( 'option' ); + const searchResultElements = within( + await screen.findByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).getAllByRole( 'option' ); - expect( searchResultElements ).toHaveLength( - fauxEntitySuggestions.length - ); + expect( searchResultElements ).toHaveLength( + fauxEntitySuggestions.length + ); - expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); + expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); - // Check that a search suggestion shows up corresponding to the data. - expect( searchResultElements[ 0 ] ).toHaveTextContent( - firstSuggestion.title - ); - expect( searchResultElements[ 0 ] ).toHaveTextContent( - firstSuggestion.type - ); + // Check that a search suggestion shows up corresponding to the data. + expect( searchResultElements[ 0 ] ).toHaveTextContent( + firstSuggestion.title + ); + expect( searchResultElements[ 0 ] ).toHaveTextContent( + firstSuggestion.type + ); - // The fallback URL suggestion should not be shown when input is not URL-like. - expect( - searchResultElements[ searchResultElements.length - 1 ] - ).not.toHaveTextContent( 'URL' ); - } ); + // The fallback URL suggestion should not be shown when input is not URL-like. + expect( + searchResultElements[ searchResultElements.length - 1 ] + ).not.toHaveTextContent( 'URL' ); + } + ); + + it.each( [ + [ 'https://wordpress.org', 'URL' ], + [ 'http://wordpress.org', 'URL' ], + [ 'www.wordpress.org', 'URL' ], + [ 'wordpress.org', 'URL' ], + [ 'ftp://wordpress.org', 'URL' ], + [ 'mailto:hello@wordpress.org', 'mailto' ], + [ 'tel:123456789', 'tel' ], + [ '#internal', 'internal' ], + ] )( + 'should display only URL result when current input value is URL-like (e.g. %s)', + async ( searchTerm, type ) => { + const user = userEvent.setup(); + + render( ); + + // Search Input UI. + const searchInput = screen.getByRole( 'combobox', { name: 'URL' } ); + + // Simulate searching for a term. + await user.type( searchInput, searchTerm ); + + const searchResultElement = within( + await screen.findByRole( 'listbox', { + name: /Search results for.*/, + } ) + ).getByRole( 'option' ); + + expect( searchResultElement ).toBeInTheDocument(); + + // Should only be the `URL` suggestion. + expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); + + expect( searchResultElement ).toHaveTextContent( searchTerm ); + expect( searchResultElement ).toHaveTextContent( type ); + expect( searchResultElement ).toHaveTextContent( + 'Press ENTER to add this link' + ); + } + ); it( 'should trim search term', async () => { const user = userEvent.setup(); @@ -504,8 +546,7 @@ describe( 'Searching for a link', () => { .flat() .filter( Boolean ); - // Given we're mocking out the results we should always have 4 mark elements. - expect( searchResultTextHighlightElements ).toHaveLength( 4 ); + expect( searchResultTextHighlightElements ).toHaveLength( 3 ); // Make sure there are no `mark` elements which contain anything other // than the trimmed search term (ie: no whitespace). @@ -565,16 +606,15 @@ describe( 'Searching for a link', () => { const lastSearchResultItem = searchResultElements[ searchResultElements.length - 1 ]; - // We should see a search result for each of the expect search suggestions - // plus 1 additional one for the fallback URL suggestion. + // We should see a search result for each of the expect search suggestions. expect( searchResultElements ).toHaveLength( - fauxEntitySuggestions.length + 1 + fauxEntitySuggestions.length ); - // The last item should be a URL search suggestion. - expect( lastSearchResultItem ).toHaveTextContent( searchTerm ); - expect( lastSearchResultItem ).toHaveTextContent( 'URL' ); - expect( lastSearchResultItem ).toHaveTextContent( + // The URL search suggestion should not exist. + expect( lastSearchResultItem ).not.toHaveTextContent( searchTerm ); + expect( lastSearchResultItem ).not.toHaveTextContent( 'URL' ); + expect( lastSearchResultItem ).not.toHaveTextContent( 'Press ENTER to add this link' ); } @@ -952,8 +992,7 @@ describe( 'Default search suggestions', () => { } ) ).getAllByRole( 'option' ); - // It should match any url that's like ?p= and also include a URL option. - expect( searchResultElements ).toHaveLength( 5 ); + expect( searchResultElements ).toHaveLength( 4 ); expect( searchInput ).toHaveAttribute( 'aria-expanded', 'true' ); diff --git a/packages/block-editor/src/components/link-control/test/is-url-like.js b/packages/block-editor/src/components/link-control/test/is-url-like.js new file mode 100644 index 0000000000000..37bec08993193 --- /dev/null +++ b/packages/block-editor/src/components/link-control/test/is-url-like.js @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import isURLLike from '../is-url-like'; + +describe( 'isURLLike', () => { + it.each( [ 'https://wordpress.org', 'http://wordpress.org' ] )( + 'returns true for a string that starts with an http(s) protocol', + ( testString ) => { + expect( isURLLike( testString ) ).toBe( true ); + } + ); + + it.each( [ + 'hello world', + 'https:// has spaces even though starts with protocol', + 'www. wordpress . org', + ] )( + 'returns false for any string with spaces (e.g. "%s")', + ( testString ) => { + expect( isURLLike( testString ) ).toBe( false ); + } + ); + + it( 'returns false for a string without a protocol or a TLD', () => { + expect( isURLLike( 'somedirectentryhere' ) ).toBe( false ); + } ); + + it( 'returns true for a string beginning with www.', () => { + expect( isURLLike( 'www.wordpress.org' ) ).toBe( true ); + } ); + + it.each( [ 'mailto:test@wordpress.org', 'tel:123456' ] )( + 'returns true for common protocols', + ( testString ) => { + expect( isURLLike( testString ) ).toBe( true ); + } + ); + + it( 'returns true for internal anchor ("hash") links.', () => { + expect( isURLLike( '#someinternallink' ) ).toBe( true ); + } ); + + // use .each to test multiple cases + it.each( [ + [ true, 'http://example.com' ], + [ true, 'https://test.co.uk?query=param' ], + [ true, 'ftp://openai.ai?param=value#section' ], + [ true, 'example.com' ], + [ true, 'http://example.com?query=param#section' ], + [ true, 'https://test.co.uk/some/path' ], + [ true, 'ftp://openai.ai/some/path' ], + [ true, 'example.org/some/path' ], + [ true, 'example_test.tld' ], + [ true, 'example_test.com' ], + [ false, 'example' ], + [ false, '.com' ], + [ true, '_test.com' ], + [ true, 'http://example_test.com' ], + ] )( + 'returns %s when testing against string "%s" for a valid TLD', + ( expected, testString ) => { + expect( isURLLike( testString ) ).toBe( expected ); + } + ); +} ); diff --git a/packages/block-editor/src/components/link-control/use-search-handler.js b/packages/block-editor/src/components/link-control/use-search-handler.js index 47c20ed60b8e3..77b91901bac5c 100644 --- a/packages/block-editor/src/components/link-control/use-search-handler.js +++ b/packages/block-editor/src/components/link-control/use-search-handler.js @@ -51,23 +51,16 @@ const handleEntitySearch = async ( val, suggestionsQuery, fetchSearchSuggestions, - directEntryHandler, withCreateSuggestion, - withURLSuggestion, pageOnFront ) => { const { isInitialSuggestions } = suggestionsQuery; - let resultsIncludeFrontPage = false; - let results = await Promise.all( [ - fetchSearchSuggestions( val, suggestionsQuery ), - directEntryHandler( val ), - ] ); + const results = await fetchSearchSuggestions( val, suggestionsQuery ); // Identify front page and update type to match. - results[ 0 ] = results[ 0 ].map( ( result ) => { + results.map( ( result ) => { if ( Number( result.id ) === pageOnFront ) { - resultsIncludeFrontPage = true; result.isFrontPage = true; return result; } @@ -75,22 +68,6 @@ const handleEntitySearch = async ( return result; } ); - const couldBeURL = ! val.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. - if ( - ! resultsIncludeFrontPage && - couldBeURL && - withURLSuggestion && - ! isInitialSuggestions - ) { - results = results[ 0 ].concat( results[ 1 ] ); - } else { - results = results[ 0 ]; - } - // If displaying initial suggestions just return plain results. if ( isInitialSuggestions ) { return results; @@ -150,12 +127,18 @@ export default function useSearchHandler( val, { ...suggestionsQuery, isInitialSuggestions }, fetchSearchSuggestions, - directEntryHandler, withCreateSuggestion, withURLSuggestion, pageOnFront ); }, - [ directEntryHandler, fetchSearchSuggestions, withCreateSuggestion ] + [ + directEntryHandler, + fetchSearchSuggestions, + pageOnFront, + suggestionsQuery, + withCreateSuggestion, + withURLSuggestion, + ] ); }