Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/block-editor/src/components/link-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ Consumers who which to take advantage of this functionality should ensure that t
When creating links the `LinkControl` component will handle two kinds of input from users:

1. Entity searches - the user may input free-text based search queries for entities retrieved from remote data sources (in the context of WordPress these are post-type entities). For example, a user might search for a `Page` they have just created by name (eg: About) and the UI will return a matching result if found.
2. Direct entry - the user may also enter any arbitrary URL-like text. This includes full URLs (https://), URL fragments (eg: `#myinternallink`), `tel` protocol links (eg: `tel: 0800 1234`) and `mailto` protocol links (eg: `mailto: hello@wordpress.org`).
2. Direct entry - the user may also enter any arbitrary URL-like text. This includes:
- Full URLs (https://)
- Domain names without protocol (example.com) - automatically prepended with https://
- URLs with www prefix (www.example.com) - automatically prepended with https://
- URL fragments (eg: `#myinternallink`)
- `tel` protocol links (eg: `tel: 0800 1234`)
- `mailto` protocol links (eg: `mailto: hello@wordpress.org`)
- Relative paths (`/page`, `./page`, `../page`)

When a URL without a valid protocol is submitted (either by pressing Enter or clicking Apply), the component automatically prepends `https://` to ensure valid links. Special protocols (mailto:, tel:), hash links, and relative paths are preserved as-is.
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README now claims URLs without a protocol are normalized when submitted “either by pressing Enter or clicking Apply”, but the Apply button submission path in LinkControl uses the raw currentUrlInputValue (only validation uses prependHTTPS). Either update the docs to only describe the Enter/suggestion behavior, or update the Apply/submit path to normalize (e.g., by applying the same normalization helper before calling onChange).

Suggested change
When a URL without a valid protocol is submitted (either by pressing Enter or clicking Apply), the component automatically prepends `https://` to ensure valid links. Special protocols (mailto:, tel:), hash links, and relative paths are preserved as-is.
When a URL without a valid protocol is submitted via the input (for example, by pressing Enter), the component automatically prepends `https://` to ensure valid links. Special protocols (mailto:, tel:), hash links, and relative paths are preserved as-is.

Copilot uses AI. Check for mistakes.

In addition, `<LinkControl>` also allows for on the fly creation of links based on the **current content of the `<input>` element**. When enabled, a default "Create new" search suggestion is appended to all non-URL-like search results.

Expand Down Expand Up @@ -350,6 +359,8 @@ See the [createSuggestion](#createSuggestion) section of this file to learn more

Suggestion selection handler, called when the user chooses one of the suggested items with `selectedValues` as the argument.

**Note:** URLs are automatically normalized before being passed to this handler. Domain names without protocols (e.g., `wordpress.org`) are prepended with `https://`, while special protocols (`mailto:`, `tel:`), hash links, and relative paths are preserved as-is.

### placeholder

- Type: `string`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { URLInput } from '../';
import LinkControlSearchResults from './search-results';
import { CREATE_TYPE } from './constants';
import useSearchHandler from './use-search-handler';
import { normalizeDirectEntryURL } from './use-search-handler';
Comment on lines 14 to +15
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a second import from the same module (./use-search-handler). The repo’s ESLint config enables import/no-duplicates, so this will fail linting. Combine these into a single import (default + named) from ./use-search-handler.

Suggested change
import useSearchHandler from './use-search-handler';
import { normalizeDirectEntryURL } from './use-search-handler';
import useSearchHandler, { normalizeDirectEntryURL } from './use-search-handler';

Copilot uses AI. Check for mistakes.

// Must be a function as otherwise URLInput will default
// to the fetchLinkSuggestions passed in block editor settings
Expand Down Expand Up @@ -163,7 +164,9 @@ const LinkControlSearchInput = forwardRef(
event.preventDefault();
} else {
onSuggestionSelected(
hasSuggestion || { url: value }
hasSuggestion || {
url: normalizeDirectEntryURL( value ),
}
);
}
} }
Expand Down
244 changes: 218 additions & 26 deletions packages/block-editor/src/components/link-control/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2981,6 +2981,190 @@ describe( 'Entity handling', () => {
);
} );

describe( 'Direct entry URL normalization', () => {
it( 'should prepend https:// to plain domain names when pressing Enter', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, 'wordpress.org' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: 'https://wordpress.org',
} )
);
} );

it( 'should prepend https:// to domain names with www prefix when pressing Enter', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, 'www.wordpress.org' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: 'https://www.wordpress.org',
} )
);
} );

it( 'should NOT prepend https:// to mailto: links', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, 'mailto:test@example.com' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: 'mailto:test@example.com',
} )
);
} );

it( 'should NOT prepend https:// to tel: links', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, 'tel:123456789' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: 'tel:123456789',
} )
);
} );

it( 'should NOT prepend https:// to hash/anchor links', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, '#section-anchor' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: '#section-anchor',
} )
);
} );

it( 'should NOT prepend https:// to relative paths', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all these "Should not prepend https://" tests should get refactored to one looping test.

const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, '/relative/path' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: '/relative/path',
} )
);
} );

it( 'should NOT double-prepend https:// to URLs that already have protocol', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ onChange }
/>
);

const searchInput = screen.getByRole( 'combobox', {
name: 'Search or type URL',
} );

await user.type( searchInput, 'https://already-has-protocol.com' );
triggerEnter( searchInput );

expect( onChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: 'https://already-has-protocol.com',
} )
);
} );
} );

describe( 'Accessibility association for entity links', () => {
it( 'should associate unlink button with help text via aria-describedby', () => {
const entityLink = {
Expand Down Expand Up @@ -3298,53 +3482,60 @@ describe( 'URL validation', () => {
{
description: 'valid URLs with protocol',
url: 'https://wordpress.org',
expectedUrl: 'https://wordpress.org',
searchPattern: /https:\/\/wordpress\.org/,
},
{
description: 'valid URLs without protocol (without http://)',
url: 'www.wordpress.org',
expectedUrl: 'https://www.wordpress.org',
searchPattern: /www\.wordpress\.org/,
},
{
description: 'hash links (internal anchor links)',
url: '#section',
expectedUrl: '#section',
searchPattern: /#section/,
},
{
description: 'relative paths (URLs starting with /)',
url: '/handbook',
expectedUrl: '/handbook',
searchPattern: /\/handbook/,
},
] )( 'should accept $description', async ( { url, searchPattern } ) => {
render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ mockOnChange }
/>
);
] )(
'should accept $description',
async ( { url, expectedUrl, searchPattern } ) => {
render(
<LinkControl
value={ { url: '' } }
forceIsEditingLink
onChange={ mockOnChange }
/>
);

const searchInput = screen.getByRole( 'combobox' );
await user.type( searchInput, url );
const searchInput = screen.getByRole( 'combobox' );
await user.type( searchInput, url );

// Wait for suggestion to appear and become stable
await screen.findByRole( 'option', {
name: searchPattern,
} );
// Wait for suggestion to appear and become stable
await screen.findByRole( 'option', {
name: searchPattern,
} );

triggerEnter( searchInput );
triggerEnter( searchInput );

// No validation error - should succeed
await waitFor( () => {
expect( mockOnChange ).toHaveBeenCalled();
} );
// No validation error - should succeed
await waitFor( () => {
expect( mockOnChange ).toHaveBeenCalled();
} );

expect( mockOnChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url,
} )
);
} );
expect( mockOnChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: expectedUrl,
} )
);
}
);

it( 'should skip validation for entity suggestions (posts, pages, categories)', async () => {
const entityLink = {
Expand Down Expand Up @@ -3484,9 +3675,10 @@ describe( 'URL validation', () => {
// a useful URL in practice. However, our validation philosophy is to
// trust the native URL constructor as the authoritative source - if the
// browser accepts it, we accept it.
// The URL is automatically prepended with https:// for consistency.
expect( mockOnChange ).toHaveBeenCalledWith(
expect.objectContaining( {
url: 'www.wordpress',
url: 'https://www.wordpress',
} )
);
} );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,37 @@ import { store as blockEditorStore } from '../../store';

export const handleNoop = () => Promise.resolve( [] );

/**
* Normalizes a URL value by prepending https:// when appropriate.
* Handles special protocols (mailto, tel), hash links, and relative paths.
*
* This is the single source of truth for URL normalization across LinkControl.
* Used by both handleDirectEntry (suggestion flow) and direct submission (Enter key).
*
* @param {string} val The URL value to normalize
* @return {string} The normalized URL with protocol if needed
*/
export function normalizeDirectEntryURL( val ) {
let type = URL_TYPE;

const protocol = getProtocol( val ) || '';

if ( protocol.includes( 'mailto' ) ) {
type = MAILTO_TYPE;
}

if ( protocol.includes( 'tel' ) ) {
type = TEL_TYPE;
}

if ( val?.startsWith( '#' ) ) {
type = INTERNAL_TYPE;
}

// Only prepend https:// for standard URLs without valid protocols
return type === URL_TYPE ? prependHTTPS( val ) : val;
Comment on lines +25 to +51
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeDirectEntryURL() duplicates the protocol/type detection logic that already exists in handleDirectEntry(). This makes it easy for the two implementations to drift (e.g., if a new type like relative path handling is added in one place but not the other). Consider refactoring to compute the type once and reuse it (e.g., a helper that returns { type, url }), or simplify normalizeDirectEntryURL() to just call prependHTTPS(val) since prependHTTPS already preserves mailto:, tel:, #, and relative paths.

Suggested change
* Handles special protocols (mailto, tel), hash links, and relative paths.
*
* This is the single source of truth for URL normalization across LinkControl.
* Used by both handleDirectEntry (suggestion flow) and direct submission (Enter key).
*
* @param {string} val The URL value to normalize
* @return {string} The normalized URL with protocol if needed
*/
export function normalizeDirectEntryURL( val ) {
let type = URL_TYPE;
const protocol = getProtocol( val ) || '';
if ( protocol.includes( 'mailto' ) ) {
type = MAILTO_TYPE;
}
if ( protocol.includes( 'tel' ) ) {
type = TEL_TYPE;
}
if ( val?.startsWith( '#' ) ) {
type = INTERNAL_TYPE;
}
// Only prepend https:// for standard URLs without valid protocols
return type === URL_TYPE ? prependHTTPS( val ) : val;
* Delegates to prependHTTPS, which preserves special protocols (mailto, tel),
* hash links, and relative paths.
*
* @param {string} val The URL value to normalize
* @return {string} The normalized URL with protocol if needed
*/
export function normalizeDirectEntryURL( val ) {
return prependHTTPS( val );

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I think this is increasing the level of complexity rather than finding a way to simplify it. I think validation and normalization can happen in one flow.

}

export const handleDirectEntry = ( val ) => {
let type = URL_TYPE;

Expand All @@ -41,7 +72,7 @@ export const handleDirectEntry = ( val ) => {
{
id: val,
title: val,
url: type === 'URL' ? prependHTTPS( val ) : val,
url: normalizeDirectEntryURL( val ),
type,
},
] );
Expand Down
Loading