diff --git a/packages/react/src/__snapshots__/golden-template.test.tsx.snap b/packages/react/src/__snapshots__/golden-template.test.tsx.snap index 1defdac..5eda26b 100644 --- a/packages/react/src/__snapshots__/golden-template.test.tsx.snap +++ b/packages/react/src/__snapshots__/golden-template.test.tsx.snap @@ -89,27 +89,27 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 - - Shop - + + Shop + - - About - + + About + - - Contact - + + Contact + @@ -175,7 +175,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
- + Shop Now @@ -284,7 +284,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
- + Buy @@ -316,7 +316,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
- + Buy diff --git a/packages/react/src/components/Button.test.tsx b/packages/react/src/components/Button.test.tsx index b5c2992..0010192 100644 --- a/packages/react/src/components/Button.test.tsx +++ b/packages/react/src/components/Button.test.tsx @@ -66,4 +66,61 @@ describe("Button Component", () => { it("has correct displayName", () => { expect(Button.displayName).toBe("Button"); }); + + // Regression: every accepted href shape must reach the rendered . + // Previously they all rendered as href="" because the mapper handed the + // exporter the schema storage shape `{ name, values: { href, target } }` + // but the exporter reads `e.url`. See semantic-props normalizeLinkValue. + describe("href is rendered to the anchor (regression)", () => { + const URL = "https://example.com"; + + function getHref(container: HTMLElement): string | null { + return container.querySelector("a")?.getAttribute("href") ?? null; + } + + it("renders href when passed as a string", () => { + const { container } = render(); + expect(getHref(container)).toBe(URL); + }); + + it("renders href when passed as the storage shape {name, values}", () => { + const { container } = render( + + ); + expect(getHref(container)).toBe(URL); + }); + + it("renders href when passed via the values escape hatch", () => { + const { container } = render( + + ); + expect(getHref(container)).toBe(URL); + }); + + it("renders href in email mode (table-based output)", () => { + const { container } = render( + + ); + expect(getHref(container)).toBe(URL); + }); + }); }); diff --git a/packages/react/src/components/Image.test.tsx b/packages/react/src/components/Image.test.tsx index 55ac087..b0b3b81 100644 --- a/packages/react/src/components/Image.test.tsx +++ b/packages/react/src/components/Image.test.tsx @@ -61,4 +61,48 @@ describe("Image Component", () => { it("has correct displayName", () => { expect(Image.displayName).toBe("Image"); }); + + // Regression: action shapes (string / storage / render) must all wrap the + // image in a working . Previously the storage shape silently produced + // no anchor at all because the exporter read `e.url` on `{ name, values }`. + describe("action wraps the image in an anchor (regression)", () => { + const URL = "https://example.com"; + const SRC = "https://example.com/photo.jpg"; + + function getAnchorHref(container: HTMLElement): string | null { + return container.querySelector("a")?.getAttribute("href") ?? null; + } + + it("renders anchor href from string action", () => { + const { container } = render( + + ); + expect(getAnchorHref(container)).toBe(URL); + }); + + it("renders anchor href from storage-shape action", () => { + const { container } = render( + + ); + expect(getAnchorHref(container)).toBe(URL); + }); + + it("renders anchor href in email mode", () => { + const { container } = render( + + ); + expect(getAnchorHref(container)).toBe(URL); + }); + }); }); diff --git a/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap b/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap index f93774a..77511bd 100644 --- a/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap +++ b/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap @@ -44,7 +44,7 @@ exports[`Render Snapshots > Body + Row + Column + Items > full tree email 1`] =
- + Click @@ -117,7 +117,7 @@ exports[`Render Snapshots > Body + Row + Column + Items > full tree web 1`] = `
@@ -182,7 +182,7 @@ exports[`Render Snapshots > Button > email 1`] = `
- + Click me @@ -193,7 +193,7 @@ exports[`Render Snapshots > Button > email 1`] = ` exports[`Render Snapshots > Button > web 1`] = ` "
diff --git a/packages/react/src/utils/create-component.tsx b/packages/react/src/utils/create-component.tsx index 3275166..a6c7ca2 100644 --- a/packages/react/src/utils/create-component.tsx +++ b/packages/react/src/utils/create-component.tsx @@ -15,6 +15,7 @@ import type { RenderMode, UnlayerConfig } from "@unlayer-internal/shared-element import { mergeValues, generateHtmlFromTextJson, + normalizeValuesForExporter, DEFAULT_CONFIG, } from "@unlayer-internal/shared-elements"; @@ -273,13 +274,21 @@ export function createItemComponent< ...bodyValues }; - // 5. Resolve exporter for this mode (fallback to web) + // 5. Resolve link/action fields to the exporter's render-value shape. + // Skipped on the renderToJson path (which uses propMapper directly) + // so JSON output preserves the schema's storage shape. + const valuesForExporter = normalizeValuesForExporter( + valuesWithMeta as Record, + config.name + ); + + // 6. Resolve exporter for this mode (fallback to web) const exporter = (config.exporters[mode] || config.exporters.web)!; - // 6. Render using utility (handles all boilerplate) + // 7. Render using utility (handles all boilerplate) return renderComponent({ type: config.name, - values: valuesWithMeta, + values: valuesForExporter as TValues, mode, className, style, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b6e87a..760d3e5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,7 +68,11 @@ export { export { mergeValues } from "./utils/merge-values"; // Utils - Semantic props -export { mapSemanticProps } from "./utils/semantic-props"; +export { + mapSemanticProps, + normalizeLinkValue, + normalizeValuesForExporter, +} from "./utils/semantic-props"; export type { SemanticProps } from "./utils/semantic-props"; // Utils - HTML to plain text diff --git a/packages/shared/src/utils/semantic-props.test.ts b/packages/shared/src/utils/semantic-props.test.ts index e40ddb4..169a18d 100644 --- a/packages/shared/src/utils/semantic-props.test.ts +++ b/packages/shared/src/utils/semantic-props.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { mapSemanticProps } from "./semantic-props"; +import { + mapSemanticProps, + normalizeLinkValue, + normalizeValuesForExporter, +} from "./semantic-props"; // Simulate Button defaults (has nested buttonColors, backgroundImage, border) const BUTTON_DEFAULTS = { @@ -191,8 +195,12 @@ describe("mapSemanticProps", () => { }); }); - describe("href normalization", () => { - it("normalizes string href to link object", () => { + describe("href normalization (mapper preserves storage shape)", () => { + // The mapper deliberately keeps the schema's storage shape so renderToJson + // round-trips back into the editor unchanged. The exporter's render shape + // (`{ url, target }`) is produced later by `normalizeValuesForExporter`. + + it("wraps string href into the storage shape {name, values}", () => { const result = mapSemanticProps( { href: "https://example.com" }, BUTTON_DEFAULTS, @@ -204,7 +212,7 @@ describe("mapSemanticProps", () => { }); }); - it("passes object href through", () => { + it("passes object href through unchanged", () => { const hrefObj = { name: "email", values: { href: "mailto:test@test.com" } }; const result = mapSemanticProps( { href: hrefObj }, @@ -243,3 +251,105 @@ describe("mapSemanticProps", () => { }); }); }); + +describe("normalizeLinkValue", () => { + it("returns undefined for null/undefined", () => { + expect(normalizeLinkValue(null)).toBeUndefined(); + expect(normalizeLinkValue(undefined)).toBeUndefined(); + }); + + it("converts a string to {url, target: '_blank'}", () => { + expect(normalizeLinkValue("https://example.com")).toEqual({ + url: "https://example.com", + target: "_blank", + }); + }); + + it("passes a render-shape value ({url, ...}) through unchanged", () => { + const v = { url: "https://example.com", target: "_self" }; + expect(normalizeLinkValue(v)).toEqual(v); + }); + + it("resolves storage shape ({name, values: {href, target}}) to render shape", () => { + expect( + normalizeLinkValue({ + name: "web", + values: { href: "https://example.com", target: "_blank" }, + }) + ).toEqual({ url: "https://example.com", target: "_blank" }); + }); + + it("defaults missing target to '_blank'", () => { + expect( + normalizeLinkValue({ name: "email", values: { href: "mailto:a@b.com" } }) + ).toEqual({ url: "mailto:a@b.com", target: "_blank" }); + }); + + it("returns undefined for unknown shapes (caller keeps original)", () => { + expect(normalizeLinkValue({ random: "thing" })).toBeUndefined(); + expect(normalizeLinkValue(42)).toBeUndefined(); + }); +}); + +describe("normalizeValuesForExporter", () => { + const STORAGE_HREF = { + name: "web", + values: { href: "https://example.com", target: "_blank" }, + }; + const RENDER_HREF = { url: "https://example.com", target: "_blank" }; + + it("normalizes top-level href (Button/Video case)", () => { + const out = normalizeValuesForExporter( + { href: STORAGE_HREF, text: "Click" }, + "Button" + ); + expect(out.href).toEqual(RENDER_HREF); + expect(out.text).toBe("Click"); + }); + + it("normalizes top-level action (Image/Timer case)", () => { + const out = normalizeValuesForExporter( + { action: STORAGE_HREF, altText: "alt" }, + "Image" + ); + expect(out.action).toEqual(RENDER_HREF); + expect(out.altText).toBe("alt"); + }); + + it("walks menu.items[].link for Menu", () => { + const out = normalizeValuesForExporter( + { + menu: { + items: [ + { key: "1", text: "Home", link: STORAGE_HREF }, + { key: "2", text: "About", link: STORAGE_HREF }, + ], + }, + }, + "Menu" + ); + expect(out.menu.items[0].link).toEqual(RENDER_HREF); + expect(out.menu.items[1].link).toEqual(RENDER_HREF); + }); + + it("does not mutate the input values", () => { + const input = { href: STORAGE_HREF }; + const out = normalizeValuesForExporter(input, "Button"); + expect(input.href).toEqual(STORAGE_HREF); // unchanged + expect(out).not.toBe(input); + }); + + it("is a no-op when there are no link fields", () => { + const input = { fontSize: "14px", color: "#000" }; + const out = normalizeValuesForExporter(input, "Heading"); + expect(out).toEqual(input); + }); + + it("leaves render-shape values as-is", () => { + const out = normalizeValuesForExporter( + { href: RENDER_HREF }, + "Button" + ); + expect(out.href).toEqual(RENDER_HREF); + }); +}); diff --git a/packages/shared/src/utils/semantic-props.ts b/packages/shared/src/utils/semantic-props.ts index 40c215a..9d617ea 100644 --- a/packages/shared/src/utils/semantic-props.ts +++ b/packages/shared/src/utils/semantic-props.ts @@ -136,16 +136,18 @@ export function mapSemanticProps>( delete result.html; } - // Handle href normalization (special case for Button/Image components) - const href = userProps.href; - if (href !== undefined) { - if (typeof href === "string") { - userProps.href = { + // String shorthand for link/action fields (`href="https://..."` or + // `action="https://..."`) → wrap into the schema's storage shape so that + // the rest of the pipeline (mergeValues, renderToJson) sees a consistent + // type. The render-time exporter handoff later resolves this into the + // exporter's `{ url, target }` shape via `normalizeValuesForExporter`. + for (const key of ["href", "action"] as const) { + const v = userProps[key]; + if (typeof v === "string") { + userProps[key] = { name: "web", - values: { href, target: "_blank" } + values: { href: v, target: "_blank" }, }; - } else { - userProps.href = href; } } @@ -202,3 +204,92 @@ export function mapSemanticProps>( return final as T; } + +/** + * Convert any of the three link-shaped inputs into the render-value shape + * the exporter expects: `{ url, target, ...customAttrs }`. + * + * The schema stores links as `{ name, values: { href, target } }`, but + * exporters (consumed via @unlayer/exporters) expect the editor's render + * value shape `{ url, target, customAttrs?, class?, onClick? }`. The editor + * runs this conversion via the link property-editor's `renderValue()` — + * `normalizeValuesForExporter` (in render-time code) mirrors it so React + * rendered output matches. + * + * Accepted inputs: + * - `"https://..."` (string shorthand) + * - `{ url, target?, customAttrs?, class?, onClick? }` (already render shape) + * - `{ name, values: { href, target? }, attrs? }` (storage shape from schema) + * + * Returns `undefined` for shapes we don't recognize so the caller can fall + * back to the original value (avoids silently dropping data). + * + * IMPORTANT: this is render-time only. Do NOT apply during renderToJson — + * JSON output is for round-tripping into the editor, which expects the + * storage shape. + */ +export function normalizeLinkValue(value: unknown): Record | undefined { + if (value == null) return undefined; + if (typeof value === "string") { + return { url: value, target: "_blank" }; + } + if (typeof value !== "object") return undefined; + const v = value as Record; + // Already render-shape (has url) — pass through. + if ("url" in v) return v; + // Storage shape from the schema: { name, values: { href, target } }. + if ("name" in v && v.values && typeof v.values === "object") { + const inner = v.values as Record; + return { + url: inner.href ?? "", + target: inner.target ?? "_blank", + ...(v.attrs ?? {}), + }; + } + return undefined; +} + +/** + * Normalize all link/action fields on an item's values to the render-value + * shape the exporter reads. Returns a shallow clone — does not mutate. + * + * Knows about the link-bearing paths per component: + * - top-level `href` (Button, Video) + * - top-level `action` (Image, Timer) + * - `menu.items[].link` (Menu) + * + * Only the bridge between mapper and exporter should call this; renderToJson + * must NOT, because JSON output preserves storage shape. + */ +export function normalizeValuesForExporter>( + values: T, + componentName: string +): T { + if (values == null || typeof values !== "object") return values; + + const out: Record = { ...values }; + + // Top-level link fields used by Button (href), Image (action), Video (href), + // Timer (action). Cheap to check both keys for every component. + for (const key of ["href", "action"]) { + if (out[key] !== undefined) { + const normalized = normalizeLinkValue(out[key]); + if (normalized !== undefined) out[key] = normalized; + } + } + + // Menu items each carry a `link` field. + if (componentName === "Menu" && out.menu && Array.isArray(out.menu.items)) { + out.menu = { + ...out.menu, + items: out.menu.items.map((item: any) => { + if (!item || item.link === undefined) return item; + const normalized = normalizeLinkValue(item.link); + if (normalized === undefined) return item; + return { ...item, link: normalized }; + }), + }; + } + + return out as T; +}