@@ -268,13 +268,13 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
}
-
+
-
+
@@ -327,17 +327,17 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
}
-
+
-
+
-
+
|
@@ -346,7 +346,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
|
-
+
|
@@ -371,7 +371,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
-
+
@@ -395,11 +395,11 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
-
+
-
+
|
@@ -408,7 +408,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
|
-
+
|
@@ -419,7 +419,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
-
+
@@ -479,17 +479,17 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
}
-
+
-
+
-
+
@@ -536,13 +536,13 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
}
-
+
-
+
@@ -587,13 +587,13 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
}
-
+
-
+
@@ -674,17 +674,17 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
}
-
+
-
+
-
+
@@ -770,7 +770,7 @@ exports[`Golden Template: Web Page (3-column) > snapshot locks the full page HTM
-
+
Blazing fast performance.
@@ -784,7 +784,7 @@ exports[`Golden Template: Web Page (3-column) > snapshot locks the full page HTM
-
+
Feature Two
@@ -804,13 +804,13 @@ exports[`Golden Template: Web Page (3-column) > snapshot locks the full page HTM
-
+
Feature Three
-
+
24/7 customer support.
diff --git a/packages/react/src/components/Body.tsx b/packages/react/src/components/Body.tsx
index a36508a..82f07b3 100644
--- a/packages/react/src/components/Body.tsx
+++ b/packages/react/src/components/Body.tsx
@@ -4,6 +4,7 @@ import type { RenderMode, UnlayerConfig, BodyValues } from "@unlayer-internal/sh
import { DEFAULT_CONFIG, mergeValues } from "@unlayer-internal/shared-elements";
import { BodyExporters } from "@unlayer/exporters";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
+import { nextHtmlId } from "../utils/create-component";
import type { SizeInput } from "../types";
import { BODY_DEFAULTS } from "../utils/container-defaults";
@@ -128,8 +129,10 @@ const Body: React.FC = (props) => {
// Resolve mode: explicit prop > config > default
const mode: RenderMode = modeProp ?? resolvedConfig.mode ?? "web";
- // Build _config to thread through children
+ // Build _config to thread through children. Reset the per-render id counters so
+ // unique ids are allocated across the whole tree (shared by reference downward).
const _config: UnlayerConfig = { ...resolvedConfig, mode };
+ (_config as { __ids?: Record }).__ids = {};
// Map semantic props, then merge BODY_DEFAULTS on top so the body always
// carries its full default values (notably contentWidth "500px", textColor
@@ -146,7 +149,7 @@ const Body: React.FC = (props) => {
const valuesWithMeta = {
...values,
_meta: {
- htmlID: `u_body_${index + 1}`,
+ htmlID: nextHtmlId(_config, "u_body"),
htmlClassNames: "u_body",
...(values._meta || {})
}
diff --git a/packages/react/src/components/Column.tsx b/packages/react/src/components/Column.tsx
index 0075acf..3a7c895 100644
--- a/packages/react/src/components/Column.tsx
+++ b/packages/react/src/components/Column.tsx
@@ -1,7 +1,7 @@
import React from "react";
import type { RenderMode, UnlayerConfig, ColumnValues } from "@unlayer-internal/shared-elements";
import { ColumnExporters, ContentExporters } from "@unlayer/exporters";
-import { UNLAYER_RENDER_KEY } from "../utils/create-component";
+import { UNLAYER_RENDER_KEY, nextHtmlId } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
import type { SizeInput, BorderInput } from "../types";
import { COLUMN_DEFAULTS } from "../utils/container-defaults";
@@ -105,7 +105,7 @@ export const Column: React.FC = (props) => {
const valuesWithMeta = {
...values,
_meta: {
- htmlID: `u_column_${index + 1}`,
+ htmlID: nextHtmlId(_config, "u_column"),
htmlClassNames: "u_column",
...(values._meta || {})
}
@@ -126,7 +126,19 @@ export const Column: React.FC = (props) => {
const ComponentType = child.type as any;
// Use __unlayerRender (hook-free) if available, otherwise call directly
const renderFn: Function = ComponentType[UNLAYER_RENDER_KEY] || ComponentType;
- const rendered = renderFn({ ...child.props, _config });
+ // Thread the column/body context so width-aware item exporters (Image)
+ // can size against the real available width (contentWidth × column
+ // fraction) instead of the fallback. Mirrors the editor, which passes
+ // this context to the content exporters.
+ const rendered = renderFn({
+ ...child.props,
+ _config,
+ colIndex: index,
+ cells,
+ bodyValues,
+ rowValues,
+ columnValues: valuesWithMeta,
+ });
// Extract HTML from dangerouslySetInnerHTML
if (
@@ -155,19 +167,24 @@ export const Column: React.FC = (props) => {
// `child.props.values?.containerPadding` only, which is undefined
// for the flat-prop API, so every block collapsed to 0px padding.)
const childProps = child.props as {
- containerPadding?: string;
- values?: { containerPadding?: string };
+ containerPadding?: string | number;
+ values?: { containerPadding?: string | number };
};
- const containerPadding =
+ const rawContainerPadding =
childProps.containerPadding ??
childProps.values?.containerPadding ??
DEFAULT_CONTAINER_PADDING;
+ // A bare number is treated as px (same idiom as other size props).
+ const containerPadding =
+ typeof rawContainerPadding === "number"
+ ? `${rawContainerPadding}px`
+ : rawContainerPadding;
// Wrap via the canonical content-container exporter for this mode.
const contentValues = {
containerPadding,
_meta: {
- htmlID: `u_content_${componentName}_${childIndex + 1}`,
+ htmlID: nextHtmlId(_config, `u_content_${componentName}`),
htmlClassNames: `u_content_${componentName}`,
},
};
diff --git a/packages/react/src/components/Image.test.tsx b/packages/react/src/components/Image.test.tsx
index 7e39491..dd2d277 100644
--- a/packages/react/src/components/Image.test.tsx
+++ b/packages/react/src/components/Image.test.tsx
@@ -3,9 +3,12 @@ import React from "react";
import { render } from "@testing-library/react";
import Image from "./Image";
import Body from "./Body";
+import Email from "./Email";
import Row from "./Row";
import { Column } from "./Column";
+import { ColumnLayouts, type ColumnLayout } from "../layouts/ColumnLayouts";
import { renderToJson } from "../utils/render-to-json";
+import { renderToHtml } from "../utils/render-to-html";
describe("Image Component", () => {
it("renders an img element", () => {
@@ -194,4 +197,39 @@ describe("Image Component", () => {
expect(src.maxWidth).toBe("50%");
});
});
+
+ // Regression: an image must size against the real available width
+ // (contentWidth × column fraction), not a fixed fallback. The Column threads
+ // its body/column context to the exporter so this matches the editor.
+ describe("sizes against contentWidth × column width (threaded to the exporter)", () => {
+ const big = { url: "https://x/p.jpg", width: 1200, height: 600 };
+ const imgWidth = (html: string) => {
+ const m = html.match(/ ]*\bwidth="(\d+)"/);
+ return m ? Number(m[1]) : null;
+ };
+ const renderImg = (contentWidth: string, layout: ColumnLayout, cols: number) => {
+ const columns = Array.from({ length: cols }, (_, i) => (
+
+
+
+ ));
+ return renderToHtml(
+
+ {columns}
+
+ );
+ };
+
+ it("a full-width image fills the content width (not the 500 fallback)", () => {
+ expect(imgWidth(renderImg("600px", ColumnLayouts.OneColumn, 1))).toBe(600);
+ });
+
+ it("an image in a 3-column row sizes to its column (~1/3), staying responsive", () => {
+ expect(imgWidth(renderImg("600px", ColumnLayouts.ThreeEqual, 3))).toBe(200);
+ });
+
+ it("respects a wider contentWidth", () => {
+ expect(imgWidth(renderImg("900px", ColumnLayouts.OneColumn, 1))).toBe(900);
+ });
+ });
});
diff --git a/packages/react/src/components/Row.tsx b/packages/react/src/components/Row.tsx
index c1cc16d..55067bf 100644
--- a/packages/react/src/components/Row.tsx
+++ b/packages/react/src/components/Row.tsx
@@ -4,6 +4,7 @@ import { validateColumnLayout } from "@unlayer-internal/shared-elements";
import type { ColumnLayout } from "@unlayer-internal/shared-elements";
import { RowExporters } from "@unlayer/exporters";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
+import { nextHtmlId } from "../utils/create-component";
import type { SizeInput } from "../types";
import { ROW_DEFAULTS, BODY_DEFAULTS } from "../utils/container-defaults";
@@ -265,7 +266,7 @@ const Row: React.FC = (props) => {
...values,
cells,
_meta: {
- htmlID: `u_row_${index + 1}`,
+ htmlID: nextHtmlId(_config, "u_row"),
htmlClassNames: "u_row",
...(values._meta || {})
}
diff --git a/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap b/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap
index 3c3cd0e..7673922 100644
--- a/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap
+++ b/packages/react/src/components/__snapshots__/snapshots.test.tsx.snap
@@ -51,7 +51,7 @@ exports[`Render Snapshots > Body + Row + Column + Items > full tree email 1`] =
|
-
+
@@ -140,7 +140,7 @@ exports[`Render Snapshots > Body + Row + Column + Items > full tree web 1`] = `
-
+
diff --git a/packages/react/src/dx-behaviors.test.tsx b/packages/react/src/dx-behaviors.test.tsx
index 796cc1b..f1a44e5 100644
--- a/packages/react/src/dx-behaviors.test.tsx
+++ b/packages/react/src/dx-behaviors.test.tsx
@@ -225,4 +225,22 @@ describe("DX: shorthands and JSON output", () => {
expect(out).not.toMatch(/failed to render/i);
expect(out).toMatch(/40px/);
});
+
+ // containerPadding (the item's content-wrapper padding) is a typed input now,
+ // and a bare number is treated as px — same as the other size props.
+ describe("containerPadding", () => {
+ it("a numeric containerPadding renders as px on the content wrapper", () => {
+ expect(html()).toMatch(/padding:\s*14px/);
+ });
+
+ it("a string containerPadding passes through", () => {
+ expect(html()).toContain("16px 24px");
+ });
+
+ it("a numeric and the equivalent px-string render identically", () => {
+ const a = html();
+ const b = html();
+ expect(a).toBe(b);
+ });
+ });
});
diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx
index a04264c..1383da0 100644
--- a/packages/react/src/dx-types.test-d.tsx
+++ b/packages/react/src/dx-types.test-d.tsx
@@ -47,6 +47,8 @@ export const _border_reject_string: ColumnProps["border"] = "1px solid #ccc";
export const _col_radius_num: ColumnProps["borderRadius"] = 8;
export const _btn_radius_num: ButtonProps["borderRadius"] = 8;
export const _btn_padding_num: ButtonProps["padding"] = 14;
+export const _btn_containerPadding_num: ButtonProps["containerPadding"] = 14;
+export const _btn_containerPadding_str: ButtonProps["containerPadding"] = "16px 24px";
export const _btn_border_factored: ButtonProps["border"] = HAIRLINE;
export const _menu_padding_num: MenuProps["padding"] = 10;
export const _table_padding_num: TableProps["padding"] = 12;
diff --git a/packages/react/src/utils/create-component.tsx b/packages/react/src/utils/create-component.tsx
index a6c7ca2..dec14ce 100644
--- a/packages/react/src/utils/create-component.tsx
+++ b/packages/react/src/utils/create-component.tsx
@@ -12,6 +12,7 @@ import type { ExporterName } from "@unlayer/types";
/** Exporter map keyed by display mode. Defined locally until added to @unlayer/types. */
type ItemExporters = Partial string>>;
import type { RenderMode, UnlayerConfig } from "@unlayer-internal/shared-elements";
+import type { SizeInput } from "../types";
import {
mergeValues,
generateHtmlFromTextJson,
@@ -41,6 +42,9 @@ export interface BaseItemComponentProps {
className?: string;
style?: React.CSSProperties;
mode?: RenderMode;
+ /** Padding of the content wrapper around this item — a number (→ px) or a CSS
+ * string ("10px", "16px 24px"). Applied by the containing Column. */
+ containerPadding?: SizeInput;
// Internal props (for advanced use)
index?: number;
@@ -48,6 +52,8 @@ export interface BaseItemComponentProps {
cells?: any[];
bodyValues?: any;
rowValues?: any;
+ /** @internal - this column's values, threaded by Column for width-aware exporters */
+ columnValues?: any;
/** @internal - Unlayer config threaded from UnlayerProvider */
_config?: UnlayerConfig;
}
@@ -124,11 +130,27 @@ interface RenderConfig {
className?: string;
style?: React.CSSProperties;
args?: any[];
+ /** Column/body context merged into the exporter `meta` (8th arg). */
+ metaContext?: Record;
innerHTML?: string;
_config?: UnlayerConfig;
exporter: Function;
}
+/**
+ * Allocate the next unique HTML id for a prefix, scoped to one render.
+ * Counters live on the threaded `_config` (`__ids`), which Body resets per
+ * render — so renderToHtml produces unique ids (u_row_1, u_row_2, …) like the
+ * editor and like renderToJson, instead of resetting per row. Falls back to
+ * `${prefix}_1` when rendered standalone (no _config).
+ */
+export function nextHtmlId(config: any, prefix: string): string {
+ if (!config) return `${prefix}_1`;
+ const ids: Record = (config.__ids ??= {});
+ ids[prefix] = (ids[prefix] || 0) + 1;
+ return `${prefix}_${ids[prefix]}`;
+}
+
/** Add _meta fields if not present. */
function ensureMeta(values: any, type: string, index: number = 0): any {
return {
@@ -146,7 +168,7 @@ function ensureMeta(values: any, type: string, index: number = 0): any {
* Handles error boundaries, exporterConfig construction, and container vs item calling conventions.
*/
function renderComponent(config: RenderConfig): JSX.Element {
- const { type, values, mode, className, style, args = [], innerHTML, _config, exporter } = config;
+ const { type, values, mode, className, style, args = [], innerHTML, _config, exporter, metaContext } = config;
try {
// Build exporterConfig from _config (falls back to defaults)
@@ -170,6 +192,7 @@ function renderComponent(config: RenderConfig): JSX.Element {
const meta = {
exporterConfig,
mergeTagState: cfg.mergeTagState,
+ ...(metaContext ?? {}),
};
html = exporter(values, ...args, undefined, meta);
}
@@ -240,6 +263,7 @@ export function createItemComponent<
cells = [],
bodyValues = {},
rowValues = {},
+ columnValues = {},
_config,
// Children
@@ -268,9 +292,12 @@ export function createItemComponent<
index
);
- // 4. Ensure bodyValues has required fields
+ // 4. Ensure bodyValues has a contentWidth. Default to the schema-shaped
+ // "500px" (matches BodyDefaults.contentWidth and the exporter's image
+ // fallback width), so a standalone item (no Body) sizes the same as the
+ // editor and the value is a CSS string everywhere it might be consumed.
const safeBodyValues = {
- contentWidth: 600,
+ contentWidth: "500px",
...bodyValues
};
@@ -293,6 +320,15 @@ export function createItemComponent<
className,
style,
args: [index, colIndex, cells, safeBodyValues, rowValues],
+ // The 8th arg the exporters actually read: column/body context for
+ // width-aware rendering (Image's available-width calc), mirroring the editor.
+ metaContext: {
+ columnIndex: colIndex,
+ columnValues,
+ rowCells: cells,
+ rowValues,
+ bodyValues: safeBodyValues,
+ },
_config,
exporter,
});
diff --git a/packages/react/src/utils/render-to-html.test.tsx b/packages/react/src/utils/render-to-html.test.tsx
index fbed9da..7f8ea50 100644
--- a/packages/react/src/utils/render-to-html.test.tsx
+++ b/packages/react/src/utils/render-to-html.test.tsx
@@ -8,8 +8,32 @@ import Paragraph from "../components/Paragraph";
import Button from "../components/Button";
import Heading from "../components/Heading";
import Image from "../components/Image";
+import Email from "../components/Email";
+import { ColumnLayouts } from "../layouts/ColumnLayouts";
describe("renderToHtml", () => {
+ it("generates unique element ids across the whole tree (no duplicates)", () => {
+ const html = renderToHtml(
+
+
+
+ A
+
+
+
+
+
+ B
+
+
+
+
+ );
+ const ids = [...html.matchAll(/id="(u_[a-z0-9_]+)"/gi)].map((m) => m[1]);
+ expect(ids.length).toBeGreaterThan(6);
+ expect(new Set(ids).size).toBe(ids.length); // every id is unique
+ });
+
it("renders a simple element to HTML string", () => {
const html = renderToHtml();
expect(typeof html).toBe("string");
| | | | |