-
+
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;
+}