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
70 changes: 35 additions & 35 deletions packages/react/src/__snapshots__/golden-template.test.tsx.snap

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions packages/react/src/components/Body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -128,8 +129,10 @@ const Body: React.FC<BodyProps> = (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<string, number> }).__ids = {};

// Map semantic props, then merge BODY_DEFAULTS on top so the body always
// carries its full default values (notably contentWidth "500px", textColor
Expand All @@ -146,7 +149,7 @@ const Body: React.FC<BodyProps> = (props) => {
const valuesWithMeta = {
...values,
_meta: {
htmlID: `u_body_${index + 1}`,
htmlID: nextHtmlId(_config, "u_body"),
htmlClassNames: "u_body",
...(values._meta || {})
}
Expand Down
31 changes: 24 additions & 7 deletions packages/react/src/components/Column.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -105,7 +105,7 @@ export const Column: React.FC<ColumnProps> = (props) => {
const valuesWithMeta = {
...values,
_meta: {
htmlID: `u_column_${index + 1}`,
htmlID: nextHtmlId(_config, "u_column"),
htmlClassNames: "u_column",
...(values._meta || {})
}
Expand All @@ -126,7 +126,19 @@ export const Column: React.FC<ColumnProps> = (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 (
Expand Down Expand Up @@ -155,19 +167,24 @@ export const Column: React.FC<ColumnProps> = (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}`,
},
};
Expand Down
38 changes: 38 additions & 0 deletions packages/react/src/components/Image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(/<img[^>]*\bwidth="(\d+)"/);
return m ? Number(m[1]) : null;
};
const renderImg = (contentWidth: string, layout: ColumnLayout, cols: number) => {
const columns = Array.from({ length: cols }, (_, i) => (
<Column key={i}>
<Image src={big} />
</Column>
));
return renderToHtml(
<Email contentWidth={contentWidth}>
<Row layout={layout}>{columns}</Row>
</Email>
);
};

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);
});
});
});
3 changes: 2 additions & 1 deletion packages/react/src/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -265,7 +266,7 @@ const Row: React.FC<RowProps> = (props) => {
...values,
cells,
_meta: {
htmlID: `u_row_${index + 1}`,
htmlID: nextHtmlId(_config, "u_row"),
htmlClassNames: "u_row",
...(values._meta || {})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ exports[`Render Snapshots > Body + Row + Column + Items > full tree email 1`] =
</tbody>
</table>

<table id="u_content_button_2" class="u_content_button" style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<table id="u_content_button_1" class="u_content_button" style="font-family:arial,helvetica,sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
<tbody>
<tr>
<td class="v-container-padding-padding" style="overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;" align="left">
Expand Down Expand Up @@ -140,7 +140,7 @@ exports[`Render Snapshots > Body + Row + Column + Items > full tree web 1`] = `

</div>

<div id="u_content_button_2" class="u_content_button v-container-padding-padding" style="overflow-wrap: break-word;padding: 10px;">
<div id="u_content_button_1" class="u_content_button v-container-padding-padding" style="overflow-wrap: break-word;padding: 10px;">

<div class="v-text-align" style="text-align: center;">
<a href="" target="_blank" class="v-size-width v-line-height v-padding v-button-colors v-border v-border-radius v-font-family v-font-size v-font-weight v-letter-spacing" style="color:#FFFFFF;background-color:#0879A1;border-radius: 4px;line-height:120%;display:inline-block;text-decoration:none;text-align:center;padding:10px 20px;width:auto;max-width:100%;word-wrap:break-word;font-size: 14px;">
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/dx-behaviors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Paragraph html="x" containerPadding={14} />)).toMatch(/padding:\s*14px/);
});

it("a string containerPadding passes through", () => {
expect(html(<Paragraph html="x" containerPadding="16px 24px" />)).toContain("16px 24px");
});

it("a numeric and the equivalent px-string render identically", () => {
const a = html(<Paragraph html="x" containerPadding={14} />);
const b = html(<Paragraph html="x" containerPadding="14px" />);
expect(a).toBe(b);
});
});
});
2 changes: 2 additions & 0 deletions packages/react/src/dx-types.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 39 additions & 3 deletions packages/react/src/utils/create-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ExporterName, (...args: any[]) => string>>;
import type { RenderMode, UnlayerConfig } from "@unlayer-internal/shared-elements";
import type { SizeInput } from "../types";
import {
mergeValues,
generateHtmlFromTextJson,
Expand Down Expand Up @@ -41,13 +42,18 @@ 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;
colIndex?: number;
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;
}
Expand Down Expand Up @@ -124,11 +130,27 @@ interface RenderConfig<T = any> {
className?: string;
style?: React.CSSProperties;
args?: any[];
/** Column/body context merged into the exporter `meta` (8th arg). */
metaContext?: Record<string, any>;
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<string, number> = (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 {
Expand All @@ -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<T = any>(config: RenderConfig<T>): 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)
Expand All @@ -170,6 +192,7 @@ function renderComponent<T = any>(config: RenderConfig<T>): JSX.Element {
const meta = {
exporterConfig,
mergeTagState: cfg.mergeTagState,
...(metaContext ?? {}),
};
html = exporter(values, ...args, undefined, meta);
}
Expand Down Expand Up @@ -240,6 +263,7 @@ export function createItemComponent<
cells = [],
bodyValues = {},
rowValues = {},
columnValues = {},
_config,

// Children
Expand Down Expand Up @@ -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
};

Expand All @@ -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,
});
Expand Down
24 changes: 24 additions & 0 deletions packages/react/src/utils/render-to-html.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Email contentWidth="600px">
<Row layout={ColumnLayouts.OneColumn}>
<Column>
<Heading>A</Heading>
<Image src="https://x/1.png" />
</Column>
</Row>
<Row layout={ColumnLayouts.OneColumn}>
<Column>
<Heading>B</Heading>
<Image src="https://x/2.png" />
</Column>
</Row>
</Email>
);
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(<Button>Click me</Button>);
expect(typeof html).toBe("string");
Expand Down
Loading