From 36995920c8a60b0046f509d13318db61788f0220 Mon Sep 17 00:00:00 2001 From: Tristan Mouchet Date: Thu, 19 Mar 2026 14:56:04 +0100 Subject: [PATCH] fix: render link tags consistently on canvas and generated pages - Remove !isEditMode guard from isButtonWithLink and shouldWrapWithLink - Add w-fit class to edit mode className for buttons rendered as - Extract shared layerLinkContext to eliminate 3 duplicate constructions - Merge button-link and -layer-link blocks into single block - Add resolveLinkAttrs utility to deduplicate href/target/rel/download --- components/LayerRenderer.tsx | 140 +++++++++++------------------------ lib/link-utils.ts | 26 +++++++ 2 files changed, 71 insertions(+), 95 deletions(-) diff --git a/components/LayerRenderer.tsx b/components/LayerRenderer.tsx index 77483f34..863f4edd 100644 --- a/components/LayerRenderer.tsx +++ b/components/LayerRenderer.tsx @@ -44,7 +44,7 @@ import FilterableCollection from '@/components/FilterableCollection'; import LocaleSelector from '@/components/layers/LocaleSelector'; import { usePagesStore } from '@/stores/usePagesStore'; import { useSettingsStore } from '@/stores/useSettingsStore'; -import { generateLinkHref, type LinkResolutionContext } from '@/lib/link-utils'; +import { generateLinkHref, resolveLinkAttrs, type LinkResolutionContext } from '@/lib/link-utils'; import { collectEditorHiddenLayerIds, type HiddenLayerInfo } from '@/lib/animation-utils'; import AnimationInitializer from '@/components/AnimationInitializer'; import { transformLayerIdsForInstance, resolveVariableLinks } from '@/lib/resolve-components'; @@ -599,7 +599,6 @@ const LayerItem: React.FC<{ // Buttons with link settings render as directly instead of being // wrapped in which is invalid HTML const isButtonWithLink = layer.name === 'button' - && !isEditMode && !isInsideForm && isValidLinkSettings(layer.variables?.link); if (isButtonWithLink) { @@ -1421,6 +1420,7 @@ const LayerItem: React.FC<{ paragraphClasses, SWIPER_CLASS_MAP[layer.name], isSlideChild && 'swiper-slide', + buttonNeedsFit && 'w-fit', enableDragDrop && !isEditing && !isLockedByOther && 'cursor-default', isDragging && 'opacity-30', showProjection && 'outline outline-1 outline-dashed outline-blue-400 bg-blue-50/10', @@ -1463,6 +1463,25 @@ const LayerItem: React.FC<{ return null; } + // Shared link resolution context — only built once, reused by button links, + // layer links, and link wrappers. Skipped in edit mode (no resolution needed). + const layerLinkContext: LinkResolutionContext | undefined = isEditMode ? undefined : { + pages, + folders, + collectionItemSlugs, + collectionItemId: collectionLayerItemId, + pageCollectionItemId, + collectionItemData: collectionLayerData, + pageCollectionItemData: pageCollectionItemData || undefined, + isPreview, + locale: currentLocale, + translations, + getAsset, + anchorMap, + resolvedAssets, + layerDataMap: effectiveLayerDataMap, + }; + // Render element-specific content const renderContent = () => { // Component instances in EDIT MODE: render component's layers directly @@ -1623,65 +1642,15 @@ const LayerItem: React.FC<{ ...(!isEditMode && { suppressHydrationWarning: true }), }; - // When a button is rendered as , apply link attributes directly - if (isButtonWithLink && layer.variables?.link) { - const btnLinkSettings = layer.variables.link; - const btnLinkContext: LinkResolutionContext = { - pages, - folders, - collectionItemSlugs, - collectionItemId: collectionLayerItemId, - pageCollectionItemId, - collectionItemData: collectionLayerData, - pageCollectionItemData: pageCollectionItemData || undefined, - isPreview, - locale: currentLocale, - translations, - getAsset, - anchorMap, - resolvedAssets, - layerDataMap: effectiveLayerDataMap, - }; - const btnLinkHref = generateLinkHref(btnLinkSettings, btnLinkContext); - if (btnLinkHref) { - elementProps.href = btnLinkHref; - elementProps.target = btnLinkSettings.target || '_self'; - const btnLinkRel = btnLinkSettings.rel || (btnLinkSettings.target === '_blank' ? 'noopener noreferrer' : undefined); - if (btnLinkRel) elementProps.rel = btnLinkRel; - if (btnLinkSettings.download) elementProps.download = btnLinkSettings.download; + // Apply link attributes for elements rendered as (buttons with links or layers) + if (htmlTag === 'a' && layer.variables?.link) { + if (isButtonWithLink) { + elementProps.role = 'button'; + delete elementProps.type; } - elementProps.role = 'button'; - delete elementProps.type; - } - - // When an layer has link settings, apply href/target/rel directly - if (htmlTag === 'a' && !isButtonWithLink && !isEditMode && layer.variables?.link) { - const aLinkSettings = layer.variables.link; - if (isValidLinkSettings(aLinkSettings)) { - const aLinkContext: LinkResolutionContext = { - pages, - folders, - collectionItemSlugs, - collectionItemId: collectionLayerItemId, - pageCollectionItemId, - collectionItemData: collectionLayerData, - pageCollectionItemData: pageCollectionItemData || undefined, - isPreview, - locale: currentLocale, - translations, - getAsset, - anchorMap, - resolvedAssets, - layerDataMap: effectiveLayerDataMap, - }; - const aLinkHref = generateLinkHref(aLinkSettings, aLinkContext); - if (aLinkHref) { - elementProps.href = aLinkHref; - elementProps.target = aLinkSettings.target || '_self'; - const aLinkRel = aLinkSettings.rel || (aLinkSettings.target === '_blank' ? 'noopener noreferrer' : undefined); - if (aLinkRel) elementProps.rel = aLinkRel; - if (aLinkSettings.download) elementProps.download = aLinkSettings.download; - } + if (layerLinkContext && isValidLinkSettings(layer.variables.link)) { + const linkAttrs = resolveLinkAttrs(layer.variables.link, layerLinkContext); + if (linkAttrs) Object.assign(elementProps, linkAttrs); } } @@ -2886,53 +2855,34 @@ const LayerItem: React.FC<{ // Don't wrap layers inside component instances (they're not directly editable) let content = renderContent(); - // Wrap with link if layer has link settings (published mode only) - // In edit mode, links are not interactive to allow layer selection + // Wrap with link if layer has link settings // Skip for buttons — they render as directly (see isButtonWithLink) // Skip for layers — they already render as and nesting inside is invalid HTML const linkSettings = layer.variables?.link; - const shouldWrapWithLink = !isEditMode - && !isButtonWithLink + const shouldWrapWithLink = !isButtonWithLink && htmlTag !== 'a' && !subtreeHasInteractiveDescendants && isValidLinkSettings(linkSettings); if (shouldWrapWithLink && linkSettings) { - // Build link context for layer-level link resolution - const layerLinkContext: LinkResolutionContext = { - pages, - folders, - collectionItemSlugs, - collectionItemId: collectionLayerItemId, - pageCollectionItemId, - collectionItemData: collectionLayerData, - pageCollectionItemData: pageCollectionItemData || undefined, - isPreview, - locale: currentLocale, - translations, - getAsset, - anchorMap, - resolvedAssets, - layerDataMap: effectiveLayerDataMap, - }; - const linkHref = generateLinkHref(linkSettings, layerLinkContext); - - if (linkHref) { - const linkTarget = linkSettings.target || '_self'; - const linkRel = linkSettings.rel || (linkTarget === '_blank' ? 'noopener noreferrer' : undefined); - const linkDownload = linkSettings.download; - + if (isEditMode) { content = ( - + {content} ); + } else if (layerLinkContext) { + const linkAttrs = resolveLinkAttrs(linkSettings, layerLinkContext); + if (linkAttrs) { + content = ( + + {content} + + ); + } } } diff --git a/lib/link-utils.ts b/lib/link-utils.ts index 7568d51a..a9a40ea3 100644 --- a/lib/link-utils.ts +++ b/lib/link-utils.ts @@ -533,3 +533,29 @@ export function looksLikePhone(value: string): boolean { const digitCount = (trimmed.match(/\d/g) || []).length; return /^[\d\s\-()+.]*$/.test(trimmed) && digitCount >= 7; } + +export interface ResolvedLinkAttrs { + href: string; + target: string; + rel?: string; + download?: boolean; +} + +/** Resolve link settings to HTML anchor attributes (href, target, rel, download) */ +export function resolveLinkAttrs( + linkSettings: LinkSettings, + context: LinkResolutionContext +): ResolvedLinkAttrs | null { + const href = generateLinkHref(linkSettings, context); + if (!href) return null; + + const target = linkSettings.target || '_self'; + const rel = linkSettings.rel || (target === '_blank' ? 'noopener noreferrer' : undefined); + + return { + href, + target, + ...(rel && { rel }), + ...(linkSettings.download && { download: linkSettings.download }), + }; +}