Skip to content
Merged
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
140 changes: 45 additions & 95 deletions components/LayerRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -599,7 +599,6 @@ const LayerItem: React.FC<{
// Buttons with link settings render as <a> directly instead of being
// wrapped in <a><button></button></a> which is invalid HTML
const isButtonWithLink = layer.name === 'button'
&& !isEditMode
&& !isInsideForm
&& isValidLinkSettings(layer.variables?.link);
if (isButtonWithLink) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1463,6 +1463,25 @@ const LayerItem: React.FC<{
return null;
}

// Shared link resolution context — only built once, reused by button links,
// <a> 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
Expand Down Expand Up @@ -1623,65 +1642,15 @@ const LayerItem: React.FC<{
...(!isEditMode && { suppressHydrationWarning: true }),
};

// When a button is rendered as <a>, 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 <a> (buttons with links or <a> layers)
if (htmlTag === 'a' && layer.variables?.link) {
if (isButtonWithLink) {
elementProps.role = 'button';
delete elementProps.type;
}
elementProps.role = 'button';
delete elementProps.type;
}

// When an <a> 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);
}
}

Expand Down Expand Up @@ -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 <a> directly (see isButtonWithLink)
// Skip for <a> layers — they already render as <a> and nesting <a> inside <a> 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 = (
<a
href={linkHref}
target={linkTarget}
rel={linkRel}
download={linkDownload || undefined}
className="contents"
>
<a className="contents">
{content}
</a>
);
} else if (layerLinkContext) {
const linkAttrs = resolveLinkAttrs(linkSettings, layerLinkContext);
if (linkAttrs) {
content = (
<a
{...linkAttrs}
className="contents"
>
{content}
</a>
);
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions lib/link-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
};
}