From f294a75ec996193827ebc5241adc3569c954738b Mon Sep 17 00:00:00 2001 From: Varshavia Date: Sun, 26 Jul 2026 02:32:16 +0300 Subject: [PATCH] fix(editor): let link and button URLs take data tokens and relative paths Binding a loop row's link to its entry page was impossible. Two gates blocked it, both on the `url` property control: 1. `BINDING_COMPATIBILITY.url` accepted only `url` and `email` fields, and `loopFieldMatchesControl` only the `url`/`media` loop formats. The `url` control binds in *token* mode -- the picker inserts `{source.field}` into the href string -- so the restriction had no basis: the one field a row detail link is built from, `slug`, is plain text and was filtered out of the picker entirely, leaving nothing clickable for the author. 2. `isValidUrl()` -- the only gate `UrlControl` runs on typed input -- required an absolute http/https/mailto URL, so it silently dropped every relative reference. `/about`, `#section`, `?q=1` and `/blog/{currentEntry.slug}` all failed it and were never written to the prop, which is why the href field appeared to stay empty. The publisher's `isSafeUrl()` accepts every relative form, so the input gate was stricter than the enforcement gate it fronts. Widen both. `url` now accepts any scalar that reads as a path segment (richText, multiSelect and boolean stay out), and `isValidUrl` accepts schemeless references, rejecting only unsafe schemes, malformed absolute URLs, and values containing whitespace. Closes #237 --- .../bindingCompatibility.test.ts | 8 +++ src/__tests__/utils/urlValidation.test.ts | 51 ++++++++++++++++--- .../DynamicBindingControl/helpers.ts | 5 +- .../property-controls/bindingCompatibility.ts | 7 ++- src/core/utils/urlValidation.ts | 29 ++++++++--- 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/__tests__/property-controls/bindingCompatibility.test.ts b/src/__tests__/property-controls/bindingCompatibility.test.ts index 080d4372d..9273e552c 100644 --- a/src/__tests__/property-controls/bindingCompatibility.test.ts +++ b/src/__tests__/property-controls/bindingCompatibility.test.ts @@ -106,10 +106,18 @@ describe('isFieldBindable', () => { expect(isFieldBindable('url', metaField('f', 'email'))).toBe(true) }) + it('url + text → true (a slug composes into an href token)', () => { + expect(isFieldBindable('url', metaField('f', 'text'))).toBe(true) + }) + it('url + boolean → false', () => { expect(isFieldBindable('url', metaField('f', 'boolean'))).toBe(false) }) + it('url + richText → false (markup is not a URL fragment)', () => { + expect(isFieldBindable('url', metaField('f', 'richText'))).toBe(false) + }) + it('group + text → false (group has no bindings)', () => { expect(isFieldBindable('group', metaField('f', 'text'))).toBe(false) }) diff --git a/src/__tests__/utils/urlValidation.test.ts b/src/__tests__/utils/urlValidation.test.ts index 4e93c491c..dcd5b095c 100644 --- a/src/__tests__/utils/urlValidation.test.ts +++ b/src/__tests__/utils/urlValidation.test.ts @@ -60,17 +60,48 @@ describe('isValidUrl', () => { }) }) - describe('malformed input', () => { - it('rejects plain text without protocol', () => { - expect(isValidUrl('not a url')).toBe(false) + describe('relative references', () => { + it('accepts a root-relative path', () => { + expect(isValidUrl('/relative/path')).toBe(true) + }) + + it('accepts document-relative paths', () => { + expect(isValidUrl('./sibling.html')).toBe(true) + expect(isValidUrl('../parent/page')).toBe(true) + expect(isValidUrl('page.html')).toBe(true) + }) + + it('accepts in-page anchors and query-only references', () => { + expect(isValidUrl('#section')).toBe(true) + expect(isValidUrl('?q=1')).toBe(true) + }) + + it('accepts protocol-relative URLs', () => { + expect(isValidUrl('//example.com')).toBe(true) + }) + }) + + describe('data tokens', () => { + it('accepts a bare token', () => { + expect(isValidUrl('{currentEntry.permalink}')).toBe(true) }) - it('rejects relative paths', () => { - expect(isValidUrl('/relative/path')).toBe(false) + it('accepts a token composed into a relative path', () => { + expect(isValidUrl('/blog/{currentEntry.slug}')).toBe(true) }) - it('rejects protocol-relative URLs', () => { - expect(isValidUrl('//example.com')).toBe(false) + it('accepts a token composed into an absolute URL', () => { + expect(isValidUrl('https://example.com/{currentEntry.slug}')).toBe(true) + }) + }) + + describe('malformed input', () => { + it('rejects plain text containing spaces', () => { + expect(isValidUrl('not a url')).toBe(false) + }) + + it('rejects a relative path containing whitespace', () => { + expect(isValidUrl('/relative/pa th')).toBe(false) }) }) }) @@ -176,5 +207,11 @@ describe('isValidImageUrl', () => { expect(isValidUrl(url)).toBe(false) expect(isValidImageUrl(url)).toBe(true) }) + + it('relative references are valid for isValidUrl but not isValidImageUrl', () => { + const url = '/images/photo.png' + expect(isValidUrl(url)).toBe(true) + expect(isValidImageUrl(url)).toBe(false) + }) }) }) diff --git a/src/admin/pages/site/property-controls/DynamicBindingControl/helpers.ts b/src/admin/pages/site/property-controls/DynamicBindingControl/helpers.ts index 96db3cfcb..0c266282c 100644 --- a/src/admin/pages/site/property-controls/DynamicBindingControl/helpers.ts +++ b/src/admin/pages/site/property-controls/DynamicBindingControl/helpers.ts @@ -44,7 +44,10 @@ export function loopFieldMatchesControl( case 'textarea': return field.format !== 'media' && field.format !== 'html' case 'url': - return field.format === 'url' || field.format === 'media' + // Same reasoning as BINDING_COMPATIBILITY.url: token insertion builds the + // href as a string, so plain-text synthetics (`slug`, `publishedAt`, …) + // are valid path segments. Only html is excluded. + return field.format !== 'html' case 'number': case 'toggle': case 'color': diff --git a/src/admin/pages/site/property-controls/bindingCompatibility.ts b/src/admin/pages/site/property-controls/bindingCompatibility.ts index d1629cda6..9590d577c 100644 --- a/src/admin/pages/site/property-controls/bindingCompatibility.ts +++ b/src/admin/pages/site/property-controls/bindingCompatibility.ts @@ -48,7 +48,12 @@ export const BINDING_COMPATIBILITY: Record