From 787e07189be57b7501a398307a26885c2bac3d41 Mon Sep 17 00:00:00 2001 From: Arukuen Date: Thu, 10 Sep 2026 11:24:34 +0800 Subject: [PATCH 1/3] feat: add an option in Stackable setting to use unrestricted link control --- src/components/link-control/editor.scss | 15 +++++++++++++ src/components/link-control/index.js | 28 ++++++++++++++++++------- src/editor-settings.php | 13 ++++++++++++ src/welcome/admin.js | 12 +++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/components/link-control/editor.scss b/src/components/link-control/editor.scss index 59267347ca..597d606a27 100644 --- a/src/components/link-control/editor.scss +++ b/src/components/link-control/editor.scss @@ -1,6 +1,7 @@ .stk-link-control__input { // Adjust width to ensure reset button is visible. width: calc(100% - 32px); + min-width: 0; > .block-editor-link-control { min-width: auto; @@ -29,6 +30,20 @@ } + > .block-editor-url-input { + min-width: 0; + width: 100%; + + .components-base-control { + min-width: 0; + width: 100%; + } + + .components-base-control__field { + margin: 0; + } + } + .block-editor-url-input__input { margin: 0 !important; width: 100% !important; diff --git a/src/components/link-control/index.js b/src/components/link-control/index.js index 3f61d874e9..8baed532dd 100644 --- a/src/components/link-control/index.js +++ b/src/components/link-control/index.js @@ -1,12 +1,14 @@ /** * External dependencies */ +import { settings } from 'stackable' import classnames from 'classnames' /** * WordPress dependencies */ import { + URLInput, __experimentalLinkControl as _LinkControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/block-editor' import { BaseControl as _BaseControl } from '@wordpress/components' @@ -25,6 +27,7 @@ const LinkControl = props => { const [ propsToPass, controlProps ] = extractControlProps( props ) const { isDynamic, + showSuggestions, ...inputProps } = propsToPass @@ -33,6 +36,8 @@ const LinkControl = props => { const dynamicContentProps = useDynamicContentControlProps( { value, onChange } ) + const useUnrestrictedURLInput = !! settings.stackable_enable_unrestricted_url_input + const classNames = classnames( [ 'stk-link-control', props.className, @@ -48,13 +53,22 @@ const LinkControl = props => { { ...dynamicContentProps } >
- <_LinkControl - { ...inputProps } - value={ { url: value } } - onChange={ ( { url } ) => onChange( url ) } - settings={ [] } // The Url only. - forceIsEditingLink={ ! value } - /> + { useUnrestrictedURLInput ? ( + + ) : ( + <_LinkControl + { ...inputProps } + value={ { url: value } } + onChange={ ( { url } ) => onChange( url ) } + settings={ [] } // The Url only. + forceIsEditingLink={ ! value } + /> + ) }
'boolean', + 'description' => __( 'Uses the legacy URL input in Stackable link controls, allowing shortcodes and other dynamic URL values.', STACKABLE_I18N ), + 'sanitize_callback' => 'rest_sanitize_boolean', + 'show_in_rest' => true, + 'default' => false, + ) + ); + register_setting( 'stackable_editor_settings', 'stackable_help_tooltip_disabled', @@ -311,6 +323,7 @@ public function add_settings( $settings ) { $settings['stackable_auto_collapse_panels'] = get_option( 'stackable_auto_collapse_panels' ); $settings['stackable_enable_global_settings'] = get_option( 'stackable_enable_global_settings' ); $settings['stackable_enable_block_linking'] = get_option( 'stackable_enable_block_linking' ); + $settings['stackable_enable_unrestricted_url_input'] = get_option( 'stackable_enable_unrestricted_url_input' ); $settings['stackable_enable_text_highlight'] = get_option( 'stackable_enable_text_highlight' ); $settings['stackable_enable_dynamic_content'] = get_option( 'stackable_enable_dynamic_content' ); $settings['stackable_enable_copy_paste_styles'] = get_option( 'stackable_enable_copy_paste_styles' ); diff --git a/src/welcome/admin.js b/src/welcome/admin.js index b621d886b6..23aeed0d15 100644 --- a/src/welcome/admin.js +++ b/src/welcome/admin.js @@ -99,6 +99,7 @@ const SEARCH_TREE = [ __( 'Stackable Settings', i18n ), __( 'Use Size Presets by Default', i18n ), __( 'Block Linking (Beta)', i18n ), + __( 'Use Unrestricted URL Input', i18n ), ], }, { @@ -788,6 +789,17 @@ const EditorSettings = props => { } /> + { + handleSettingsChange( { stackable_enable_unrestricted_url_input: value } ) // eslint-disable-line camelcase + } } + help={ __( 'Uses the legacy URL field in all Stackable link controls. Enable this for shortcodes, template tokens, or other dynamic URL values. This disables Gutenberg URL validation.', i18n ) } + disabled={ __( 'Use Gutenberg URL input', i18n ) } + enabled={ __( 'Use unrestricted URL input', i18n ) } + /> } { ( toolbar.children === null || toolbar.children.length > 0 ) && From 82c570cf06d25893495fd09c58daf07170fce6e8 Mon Sep 17 00:00:00 2001 From: Arukuen Date: Thu, 17 Sep 2026 09:42:44 +0800 Subject: [PATCH 2/3] fix: undo adding of option, use URLInput for stackable LinkControl --- src/components/link-control/editor.scss | 65 ------------------------- src/components/link-control/index.js | 32 +++--------- src/editor-settings.php | 13 ----- src/welcome/admin.js | 12 ----- 4 files changed, 7 insertions(+), 115 deletions(-) diff --git a/src/components/link-control/editor.scss b/src/components/link-control/editor.scss index 597d606a27..102abea8c6 100644 --- a/src/components/link-control/editor.scss +++ b/src/components/link-control/editor.scss @@ -3,33 +3,6 @@ width: calc(100% - 32px); min-width: 0; - > .block-editor-link-control { - min-width: auto; - width: 100%; - - .components-base-control { - min-width: auto; - } - - .components-input-base { - height: 30px; - } - - .block-editor-link-control__search-input-wrapper { - margin: 0; - } - - .block-editor-link-control__field { - margin: 0; - } - - .block-editor-link-control__search-item-header { - overflow: hidden; - white-space: nowrap; - } - - } - > .block-editor-url-input { min-width: 0; width: 100%; @@ -50,47 +23,9 @@ height: 30px !important; padding: 6px 8px !important; } - .block-editor-link-control__search-actions { - display: none; - } - .block-editor-link-control__search-results-wrapper { - margin-bottom: 24px !important; - margin-top: 0 !important; - .block-editor-link-control__search-results { - margin: 0; - } - } - .block-editor-link-control__search-item { - flex-wrap: wrap; - } - .block-editor-link-control__search-item.is-current { - margin-top: -2px; - padding: 0 !important; - } - .block-editor-link-control__search-item-details { - max-width: 140px !important; - overflow: hidden; - } - - .block-editor-link-control__search-item-icon { - display: none; - } - - .block-editor-link-control__search-enter { - position: absolute; - top: 0; - right: 3px; - button:hover { - box-shadow: none !important; - } - } } -// Adjust the location of the dynamic and reset buttons since our control is taller. .stk-link-control { - .block-editor-link-control__field { - margin: auto; - } .stk-dynamic-content-control { display: flex; align-items: center; diff --git a/src/components/link-control/index.js b/src/components/link-control/index.js index 8baed532dd..3090af0751 100644 --- a/src/components/link-control/index.js +++ b/src/components/link-control/index.js @@ -1,18 +1,12 @@ /** * External dependencies */ -import { settings } from 'stackable' import classnames from 'classnames' /** * WordPress dependencies */ -import { - URLInput, - __experimentalLinkControl as _LinkControl, // eslint-disable-line @wordpress/no-unsafe-wp-apis -} from '@wordpress/block-editor' -import { BaseControl as _BaseControl } from '@wordpress/components' -import { __ } from '@wordpress/i18n' +import { URLInput } from '@wordpress/block-editor' /** * Internal dependencies @@ -36,8 +30,6 @@ const LinkControl = props => { const dynamicContentProps = useDynamicContentControlProps( { value, onChange } ) - const useUnrestrictedURLInput = !! settings.stackable_enable_unrestricted_url_input - const classNames = classnames( [ 'stk-link-control', props.className, @@ -53,22 +45,12 @@ const LinkControl = props => { { ...dynamicContentProps } >
- { useUnrestrictedURLInput ? ( - - ) : ( - <_LinkControl - { ...inputProps } - value={ { url: value } } - onChange={ ( { url } ) => onChange( url ) } - settings={ [] } // The Url only. - forceIsEditingLink={ ! value } - /> - ) } +
'boolean', - 'description' => __( 'Uses the legacy URL input in Stackable link controls, allowing shortcodes and other dynamic URL values.', STACKABLE_I18N ), - 'sanitize_callback' => 'rest_sanitize_boolean', - 'show_in_rest' => true, - 'default' => false, - ) - ); - register_setting( 'stackable_editor_settings', 'stackable_help_tooltip_disabled', @@ -323,7 +311,6 @@ public function add_settings( $settings ) { $settings['stackable_auto_collapse_panels'] = get_option( 'stackable_auto_collapse_panels' ); $settings['stackable_enable_global_settings'] = get_option( 'stackable_enable_global_settings' ); $settings['stackable_enable_block_linking'] = get_option( 'stackable_enable_block_linking' ); - $settings['stackable_enable_unrestricted_url_input'] = get_option( 'stackable_enable_unrestricted_url_input' ); $settings['stackable_enable_text_highlight'] = get_option( 'stackable_enable_text_highlight' ); $settings['stackable_enable_dynamic_content'] = get_option( 'stackable_enable_dynamic_content' ); $settings['stackable_enable_copy_paste_styles'] = get_option( 'stackable_enable_copy_paste_styles' ); diff --git a/src/welcome/admin.js b/src/welcome/admin.js index 23aeed0d15..b621d886b6 100644 --- a/src/welcome/admin.js +++ b/src/welcome/admin.js @@ -99,7 +99,6 @@ const SEARCH_TREE = [ __( 'Stackable Settings', i18n ), __( 'Use Size Presets by Default', i18n ), __( 'Block Linking (Beta)', i18n ), - __( 'Use Unrestricted URL Input', i18n ), ], }, { @@ -789,17 +788,6 @@ const EditorSettings = props => { } /> - { - handleSettingsChange( { stackable_enable_unrestricted_url_input: value } ) // eslint-disable-line camelcase - } } - help={ __( 'Uses the legacy URL field in all Stackable link controls. Enable this for shortcodes, template tokens, or other dynamic URL values. This disables Gutenberg URL validation.', i18n ) } - disabled={ __( 'Use Gutenberg URL input', i18n ) } - enabled={ __( 'Use unrestricted URL input', i18n ) } - /> } { ( toolbar.children === null || toolbar.children.length > 0 ) && From cf30d48b75a39d2f24f233029ce21f4d3c6fe916 Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 18 Sep 2026 16:41:10 +0800 Subject: [PATCH 3/3] feat: validate Stackable link URLs while allowing shortcodes and tokens Gutenberg's LinkControl now rejects shortcodes and dynamic values, so Stackable link fields use a direct URL input with URL-like validation instead of a settings toggle. --- e2e/tests/link-control.spec.ts | 104 ++++++++++++ .../link-control/__test__/validate.test.js | 71 ++++++++ src/components/link-control/editor.scss | 9 +- src/components/link-control/index.js | 29 +++- src/components/link-control/validate.js | 157 ++++++++++++++++++ 5 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 e2e/tests/link-control.spec.ts create mode 100644 src/components/link-control/__test__/validate.test.js create mode 100644 src/components/link-control/validate.js diff --git a/e2e/tests/link-control.spec.ts b/e2e/tests/link-control.spec.ts new file mode 100644 index 0000000000..5c8a856980 --- /dev/null +++ b/e2e/tests/link-control.spec.ts @@ -0,0 +1,104 @@ +import { + test, + expect, + waitForBlockEditor, +} from 'e2e/test-utils' + +test.describe( 'Link control URL input', () => { + const createdPostIds: Array = [] + + test.afterEach( async ( { requestUtils } ) => { + for ( const id of createdPostIds.splice( 0 ) ) { + await requestUtils.deletePost( id ).catch( () => undefined ) + } + } ) + + test( 'accepts URLs, shortcodes, and other non-URL values', async ( { + page, + admin, + editor, + stackable, + } ) => { + await admin.createNewPost( { title: 'Link Control URL Input' } ) + await editor.saveDraft() + const postQuery = new URL( editor.page.url() ).search + const postId = new URLSearchParams( postQuery ).get( 'post' ) + if ( postId ) { + createdPostIds.push( postId ) + } + + await stackable.dismissToursAndNotices() + await waitForBlockEditor( editor ) + + await editor.insertBlock( { name: 'stackable/button-group' } ) + await stackable.pickDefaultLayout( editor ) + await stackable.selectBlockByName( editor, 'stackable/button' ) + + await stackable.openInspectorTab( 'Style' ) + + const inspector = page.getByRole( 'region', { name: 'Editor settings' } ) + const linkPanel = inspector.locator( '.ugb-toggle-panel-body.ugb-panel--link' ) + await expect( linkPanel ).toBeVisible() + if ( ! ( await linkPanel.getAttribute( 'class' ) || '' ).includes( 'is-opened' ) ) { + await linkPanel.locator( '.components-panel__body-toggle' ).click() + } + await expect( linkPanel ).toHaveClass( /is-opened/ ) + + const linkControl = linkPanel.locator( '.stk-link-control' ).filter( { + has: page.locator( '.stk-control-label', { hasText: /Link \/ URL/ } ), + } ) + const input = linkControl.getByRole( 'combobox', { name: 'URL' } ) + await expect( input ).toBeVisible() + + const buttonBlock = editor.canvas.locator( '[data-type="stackable/button"]' ).first() + const clientId = await buttonBlock.getAttribute( 'data-block' ) + + const setLinkValue = async ( value: string ) => { + await input.click() + await input.fill( value ) + await page.keyboard.press( 'Escape' ) + await input.blur() + } + + await setLinkValue( 'https://example.com' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( 'https://example.com' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( 'example.com' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( 'https://example.com' ) + + await setLinkValue( '[my_shortcode]' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( '[my_shortcode]' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( '!#stk_dynamic/current-page/url!#' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( '!#stk_dynamic/current-page/url!#' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( '{{permalink}}' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( '{{permalink}}' ) + await expect( linkControl ).not.toContainText( 'Please enter a valid URL.' ) + + await setLinkValue( 'https://' ) + await expect.poll( async () => { + const attributes = await editor.getBlockAttributes( clientId ) + return attributes.linkUrl + } ).toBe( 'https://' ) + await expect( linkControl ).toContainText( 'Please enter a valid URL.' ) + } ) +} ) diff --git a/src/components/link-control/__test__/validate.test.js b/src/components/link-control/__test__/validate.test.js new file mode 100644 index 0000000000..e2bb1377e6 --- /dev/null +++ b/src/components/link-control/__test__/validate.test.js @@ -0,0 +1,71 @@ +/** + * Internal dependencies + */ +import { + isPassThroughLinkValue, + isUrlLike, + isValidLinkValue, + normalizeLinkValue, +} from '../validate' + +describe( 'link-control validation', () => { + it( 'treats empty values as pass-through', () => { + expect( isPassThroughLinkValue( '' ) ).toBe( true ) + expect( isPassThroughLinkValue( ' ' ) ).toBe( true ) + expect( isPassThroughLinkValue( undefined ) ).toBe( true ) + expect( isValidLinkValue( '' ) ).toBe( true ) + } ) + + it( 'allows shortcodes without treating them as URLs', () => { + expect( isPassThroughLinkValue( '[my_shortcode]' ) ).toBe( true ) + expect( isPassThroughLinkValue( '[contact-form-7 id="1"]' ) ).toBe( true ) + expect( isPassThroughLinkValue( '[site.url]' ) ).toBe( true ) + expect( isValidLinkValue( '[my_shortcode]' ) ).toBe( true ) + expect( normalizeLinkValue( '[my_shortcode]' ) ).toBe( '[my_shortcode]' ) + } ) + + it( 'allows dynamic content tokens', () => { + const token = '!#stk_dynamic/current-page/url!#' + expect( isPassThroughLinkValue( token ) ).toBe( true ) + expect( isValidLinkValue( token ) ).toBe( true ) + expect( normalizeLinkValue( token ) ).toBe( token ) + } ) + + it( 'allows other non-URL strings', () => { + expect( isPassThroughLinkValue( '{{permalink}}' ) ).toBe( true ) + expect( isPassThroughLinkValue( '%post_url%' ) ).toBe( true ) + expect( isPassThroughLinkValue( 'hello' ) ).toBe( true ) + expect( isValidLinkValue( 'hello' ) ).toBe( true ) + expect( isUrlLike( 'hello' ) ).toBe( false ) + } ) + + it( 'recognizes URL-like values', () => { + expect( isUrlLike( 'https://example.com' ) ).toBe( true ) + expect( isUrlLike( 'example.com' ) ).toBe( true ) + expect( isUrlLike( 'www.example.com' ) ).toBe( true ) + expect( isUrlLike( '#section' ) ).toBe( true ) + expect( isUrlLike( '/about' ) ).toBe( true ) + expect( isUrlLike( 'mailto:hi@example.com' ) ).toBe( true ) + } ) + + it( 'accepts valid URLs, anchors, and relative paths', () => { + expect( isValidLinkValue( 'https://example.com' ) ).toBe( true ) + expect( isValidLinkValue( '#section' ) ).toBe( true ) + expect( isValidLinkValue( '/about' ) ).toBe( true ) + expect( isValidLinkValue( '../parent' ) ).toBe( true ) + expect( isValidLinkValue( 'mailto:hi@example.com' ) ).toBe( true ) + } ) + + it( 'rejects incomplete URL-like values', () => { + expect( isValidLinkValue( 'https://' ) ).toBe( false ) + expect( isValidLinkValue( 'http://' ) ).toBe( false ) + } ) + + it( 'prepends https to bare domains on normalize', () => { + expect( normalizeLinkValue( 'example.com' ) ).toBe( 'https://example.com' ) + expect( normalizeLinkValue( ' example.com ' ) ).toBe( 'https://example.com' ) + expect( normalizeLinkValue( 'https://example.com' ) ).toBe( 'https://example.com' ) + expect( normalizeLinkValue( '#section' ) ).toBe( '#section' ) + expect( normalizeLinkValue( '/about' ) ).toBe( '/about' ) + } ) +} ) diff --git a/src/components/link-control/editor.scss b/src/components/link-control/editor.scss index 102abea8c6..9bead0ff58 100644 --- a/src/components/link-control/editor.scss +++ b/src/components/link-control/editor.scss @@ -17,7 +17,8 @@ } } - .block-editor-url-input__input { + .block-editor-url-input__input, + .components-input-control__input { margin: 0 !important; width: 100% !important; height: 30px !important; @@ -25,6 +26,12 @@ } } +.stk-link-control--invalid { + .components-base-control__help { + color: #cc1818; + } +} + .stk-link-control { .stk-dynamic-content-control { display: flex; diff --git a/src/components/link-control/index.js b/src/components/link-control/index.js index 3090af0751..40e504fe6e 100644 --- a/src/components/link-control/index.js +++ b/src/components/link-control/index.js @@ -2,11 +2,13 @@ * External dependencies */ import classnames from 'classnames' +import { i18n } from 'stackable' /** * WordPress dependencies */ import { URLInput } from '@wordpress/block-editor' +import { __ } from '@wordpress/i18n' /** * Internal dependencies @@ -15,6 +17,10 @@ import DynamicContentControl, { useDynamicContentControlProps } from '../dynamic import AdvancedControl, { extractControlProps } from '../base-control2' import { useControlHandlers } from '../base-control2/hooks' import { ResetButton } from '../base-control2/reset-button' +import { + isValidLinkValue, + normalizeLinkValue, +} from './validate' const LinkControl = props => { const [ _value, _onChange ] = useControlHandlers( props.attribute, props.responsive, props.hover, props.valueCallback, props.changeCallback ) @@ -27,6 +33,9 @@ const LinkControl = props => { const value = typeof props.value === 'undefined' ? _value : props.value const onChange = typeof props.onChange === 'undefined' ? _onChange : props.onChange + const urlError = value && ! isValidLinkValue( value ) + ? __( 'Please enter a valid URL.', i18n ) + : '' const dynamicContentProps = useDynamicContentControlProps( { value, onChange } ) @@ -35,21 +44,37 @@ const LinkControl = props => { props.className, ], { 'stk--has-value': value, + 'stk-link-control--invalid': urlError, } ) + const handleBlur = () => { + const normalized = normalizeLinkValue( value ) + if ( normalized !== value ) { + onChange( normalized ) + } + } + return ( - + -
+
diff --git a/src/components/link-control/validate.js b/src/components/link-control/validate.js new file mode 100644 index 0000000000..2c32388777 --- /dev/null +++ b/src/components/link-control/validate.js @@ -0,0 +1,157 @@ +/** + * Link values that should skip Gutenberg-style URL validation. + * + * Stackable link fields accept shortcodes, dynamic content tokens, and other + * non-URL strings that Gutenberg's LinkControl now rejects. + */ + +/** + * WordPress dependencies + */ +import { + getProtocol, + isValidFragment, + isValidProtocol, + prependHTTP, +} from '@wordpress/url' + +const SHORTCODE_VALUE = /^\s*\[[^\]]+\]/ + +const prependHTTPS = url => { + const withProtocol = prependHTTP( url ) + return withProtocol.startsWith( 'http://' ) + ? `https://${ withProtocol.slice( 'http://'.length ) }` + : withProtocol +} + +const hasDynamicContentToken = value => + value.includes( '!#stk_dynamic' ) || value.includes( 'data-stk-dynamic' ) + +const isHashLink = value => value.startsWith( '#' ) && isValidFragment( value ) + +const isRelativePath = value => + value.startsWith( '/' ) || + value.startsWith( './' ) || + value.startsWith( '../' ) + +/** + * True when the string looks like a domain with a TLD, e.g. `example.com`. + * + * Mirrors Gutenberg's LinkControl heuristic. + * + * @param {string} url + * @param {number} maxLength + * @return {boolean} Whether the value has a possible TLD. + */ +const hasPossibleTLD = ( url, maxLength = 6 ) => { + const cleanedURL = url.split( /[?#]/ )[ 0 ] + return new RegExp( `\\S\\.[a-zA-Z_]{2,${ maxLength }}(?:\\/|$)` ).test( cleanedURL ) +} + +/** + * True when the value should be treated as a URL rather than a free-form string. + * + * @param {string} value + * @return {boolean} Whether the value looks like a URL. + */ +export const isUrlLike = value => { + if ( ! value || value.includes( ' ' ) ) { + return false + } + + const protocol = getProtocol( value ) + + return ( + isValidProtocol( protocol ) || + value.startsWith( 'www.' ) || + isHashLink( value ) || + hasPossibleTLD( value ) || + isRelativePath( value ) + ) +} + +/** + * True when URL validation should not run. + * + * @param {string} value + * @return {boolean} Whether the value is a shortcode, dynamic token, or other non-URL. + */ +export const isPassThroughLinkValue = value => { + if ( ! value || typeof value !== 'string' ) { + return true + } + + const trimmed = value.trim() + if ( ! trimmed ) { + return true + } + + if ( hasDynamicContentToken( trimmed ) ) { + return true + } + + if ( SHORTCODE_VALUE.test( trimmed ) ) { + return true + } + + return ! isUrlLike( trimmed ) +} + +/** + * True when a URL-like value can be parsed as a URL. + * + * @param {string} value + * @return {boolean} Whether the URL-like value is valid. + */ +export const isValidUrlLikeValue = value => { + if ( isHashLink( value ) || isRelativePath( value ) ) { + return true + } + + try { + // eslint-disable-next-line no-new + new URL( prependHTTPS( value ) ) + return true + } catch { + return false + } +} + +/** + * True when the link field may keep this value. + * + * @param {string} value + * @return {boolean} Whether the value is allowed. + */ +export const isValidLinkValue = value => { + if ( isPassThroughLinkValue( value ) ) { + return true + } + + return isValidUrlLikeValue( value.trim() ) +} + +/** + * Normalize URL-like values by trimming and prepending https when needed. + * Shortcodes, dynamic content, and other non-URLs are left as entered. + * + * @param {string} value + * @return {string} Normalized value. + */ +export const normalizeLinkValue = value => { + if ( typeof value !== 'string' ) { + return value + } + + const trimmed = value.trim() + if ( isPassThroughLinkValue( trimmed ) ) { + return trimmed + } + + if ( isHashLink( trimmed ) || isRelativePath( trimmed ) ) { + return trimmed + } + + const normalized = prependHTTPS( trimmed ) + return isValidUrlLikeValue( normalized ) ? normalized : value +}