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
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
<tr>
<td class="v-text-align" style="padding-right: 0px;padding-left: 0px;" align="center">

<img align="center" border="0" src="https://example.com/logo.png" alt="Acme Co" title="Acme Co" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 560px;" width="560" height="140" class="v-src-width v-src-max-width"/>
<img align="center" border="0" src="https://example.com/logo.png" alt="Acme Co" title="Acme Co" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 560px;" width="560" class="v-src-width v-src-max-width"/>

</td>
</tr>
Expand Down Expand Up @@ -183,7 +183,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
<tr>
<td class="v-text-align" style="padding-right: 0px;padding-left: 0px;" align="center">

<img align="center" border="0" src="https://example.com/hero.jpg" alt="Spring Collection" title="Spring Collection" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 600px;" width="600" height="150" class="v-src-width v-src-max-width"/>
<img align="center" border="0" src="https://example.com/hero.jpg" alt="Spring Collection" title="Spring Collection" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 600px;" width="600" class="v-src-width v-src-max-width"/>

</td>
</tr>
Expand Down Expand Up @@ -346,7 +346,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
<tr>
<td class="v-text-align" style="padding-right: 0px;padding-left: 0px;" align="center">

<img align="center" border="0" src="https://example.com/product-a.jpg" alt="Product A" title="Product A" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 300px;" width="300" height="75" class="v-src-width v-src-max-width"/>
<img align="center" border="0" src="https://example.com/product-a.jpg" alt="Product A" title="Product A" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 300px;" width="300" class="v-src-width v-src-max-width"/>

</td>
</tr>
Expand Down Expand Up @@ -408,7 +408,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1
<tr>
<td class="v-text-align" style="padding-right: 0px;padding-left: 0px;" align="center">

<img align="center" border="0" src="https://example.com/product-b.jpg" alt="Product B" title="Product B" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 300px;" width="300" height="75" class="v-src-width v-src-max-width"/>
<img align="center" border="0" src="https://example.com/product-b.jpg" alt="Product B" title="Product B" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 300px;" width="300" class="v-src-width v-src-max-width"/>

</td>
</tr>
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const DEFAULT_VALUES = {
*
* @example Flat Props (Simple - most common)
* ```tsx
* <Button color="white" backgroundColor="#3b82f6" fontSize="16px">
* // `href` accepts a plain URL string (the ergonomic form).
* <Button href="https://example.com" color="white" backgroundColor="#3b82f6" fontSize="16px">
* Click me
* </Button>
* ```
Expand Down
32 changes: 32 additions & 0 deletions packages/react/src/components/Html.stories.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import * as stories from "./Html.stories";

// The <Html> component is a raw passthrough (no default sanitizer), so its story
// HTML must itself be valid and safe. Two things have bitten us:
// - inline event handlers (onmouseover, …) — never run in a rendered email and
// are an XSS pattern; bad examples to ship.
// - a `url('…')` whose content has a raw `"` (e.g. an SVG data URI with
// xmlns="…") sits inside a double-quoted style="…", so the first inner `"`
// closes the attribute and the tail (`'); opacity: …">`) leaks as visible
// text. URL-encode the data URI or avoid raw double quotes.
const htmlStories = Object.entries(stories).filter(
([, s]) => s && typeof s === "object" && (s as any).args && typeof (s as any).args.html === "string"
) as [string, { args: { html: string } }][];

describe("Html stories ship valid, safe HTML", () => {
it("there are several html stories to check", () => {
expect(htmlStories.length).toBeGreaterThan(3);
});

for (const [name, story] of htmlStories) {
const html = story.args.html;

it(`${name}: has no inline event handlers`, () => {
expect(html).not.toMatch(/\son[a-z]+\s*=/i);
});

it(`${name}: has no url() containing a raw double quote (style break-out)`, () => {
expect(html).not.toMatch(/url\(\s*'[^']*"[^']*'\s*\)/);
});
}
});
11 changes: 6 additions & 5 deletions packages/react/src/components/Html.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const StyledCard: Story = {
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
" onmouseover="this.style.background='rgba(255,255,255,0.3)'" onmouseout="this.style.background='rgba(255,255,255,0.2)'">
">
Learn More
</button>
</div>
Expand Down Expand Up @@ -172,8 +172,9 @@ export const CallToAction: Story = {
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="%23374151" opacity="0.4"/><circle cx="75" cy="75" r="1" fill="%23374151" opacity="0.4"/><circle cx="50" cy="10" r="1" fill="%23374151" opacity="0.4"/><circle cx="10" cy="50" r="1" fill="%23374151" opacity="0.4"/><circle cx="90" cy="30" r="1" fill="%23374151" opacity="0.4"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.1;
background-image: radial-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px);
background-size: 18px 18px;
opacity: 0.5;
"></div>

<div style="position: relative; z-index: 1;">
Expand Down Expand Up @@ -213,7 +214,7 @@ export const CallToAction: Story = {
cursor: pointer;
transition: transform 0.2s ease;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
" onmouseover="this.style.transform='translateY(-2px)'" onmouseout="this.style.transform='translateY(0)'">
">
Start Free Trial
</button>

Expand All @@ -227,7 +228,7 @@ export const CallToAction: Story = {
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
" onmouseover="this.style.borderColor='rgba(255,255,255,0.6)'; this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.borderColor='rgba(255,255,255,0.3)'; this.style.background='transparent'">
">
Learn More
</button>
</div>
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/components/Html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ const DEFAULT_VALUES = {
/**
* Html - Universal SSR/Client Component for custom HTML with Automatic Semantic Props
*
* ⚠️ Renders the HTML verbatim — it is NOT sanitized by default. Only pass HTML
* you trust, and make sure it is valid: notably, an inline SVG inside a `url(...)`
* must be URL-encoded, because a raw `"` inside a double-quoted `style="…"`
* closes the attribute and the rest leaks out as text. To sanitize (matching the
* editor's HTML block, which strips scripts/event handlers), pass a `toSafeHtml`
* function via the `UnlayerProvider` config.
*
* @example Flat Props
* ```tsx
* <Html html="<div>Custom HTML</div>" />
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/components/Image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,16 @@ describe("Image Component", () => {
return (json as any).body.rows[0].columns[0].contents[0].values.src;
}

it("a natural width (object src) stays responsive — not pinned", () => {
it("an object-src width pins as display intent (like the flat width prop)", () => {
// The documented full-control form `src={{ width }}` must pin and survive a
// round-trip, same as `width={…}` — not serialize autoWidth:true.
const src = imageSrc(
<Image src={{ url: "https://x/p.jpg", width: 300, height: 200 } as any} />
);
expect(src.autoWidth).toBe(true);
expect(src.width).toBe(300);
expect(src.maxWidth).toBe("100%");
expect(src.autoWidth).toBe(false);
expect(src.maxWidth).toBe("62.5%"); // 300 / (500 default − 20px slot)
expect(src.width).toBe(300); // natural/aspect fields preserved
expect(src.height).toBe(200);
});

it("no width / string src stays responsive (autoWidth:true)", () => {
Expand Down
35 changes: 22 additions & 13 deletions packages/react/src/components/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ type ImageSemanticProps = Omit<

export interface ImageProps extends ItemComponentProps<ImageSemanticProps> {}

// Defaults from the editor schema, plus React-specific overrides
// Defaults from the editor schema, plus React-specific overrides.
// Drop the schema placeholder's `height` so an image with no explicit dimensions
// doesn't inherit its 4:1 aspect ratio: the email exporter then omits the `height`
// attribute (height:auto), letting the real image keep its own ratio instead of
// being letterboxed (Outlook honors the height attribute). `width` is kept — it
// drives the responsive display width attribute. Real dimensions, when provided
// via an object `src`, override these.
const { height: _placeholderHeight, ...defaultSrc } = ImageDefaults.src as Record<string, unknown>;
const DEFAULT_VALUES = {
...ImageDefaults,
// Override src with autoWidth/maxWidth for responsive rendering
src: {
...ImageDefaults.src,
...defaultSrc,
autoWidth: true,
maxWidth: "100%",
},
Expand Down Expand Up @@ -112,14 +118,17 @@ const Image = createItemComponent<ImageValues, ImageSemanticProps>({
: { ...DEFAULT_VALUES.src };
const merged = { ...start, ...userSrc } as Record<string, any>;

// In Unlayer's value model, `src.width`/`height` are the NATURAL image size
// and never set the display size. Display size = autoWidth + maxWidth: the
// default (and "100%") is responsive (autoWidth:true); a fixed display size
// is autoWidth:false + `maxWidth` as a PERCENT of the column's content slot
// (see image-sizing.ts for why a percent, not the natural-size field). A
// px/number pin is kept here as a placeholder and converted to that percent
// by the width-aware pass in renderToHtml / renderToJson, where the column
// geometry is known. An explicit autoWidth is honored.
// Display size = autoWidth + maxWidth: the default is responsive
// (autoWidth:true); a fixed display size is autoWidth:false + `maxWidth` as a
// PERCENT of the column's content slot (see image-sizing.ts for why a
// percent, not a raw px). A px/number pin is kept here as a placeholder and
// converted to that percent by the width-aware pass in renderToHtml /
// renderToJson, where the column geometry is known.
//
// A `width` the author provides — the flat `width` prop OR `src.width`
// (the documented full-control form) — is treated as that display intent and
// pins. This is the canonical pinned shape the editor stores, so it survives
// the round-trip; an `autoWidth` set explicitly on `src` is honored as-is.
const pctRe = /^\d+(?:\.\d+)?%$/;
const asPercent = (v: unknown): string | undefined =>
typeof v === "string" && pctRe.test(v.trim()) ? v.trim() : undefined;
Expand All @@ -135,10 +144,10 @@ const Image = createItemComponent<ImageValues, ImageSemanticProps>({
};

// Resolve display intent in priority order: flat `width` → flat `maxWidth`
// → escape-hatch values.src.maxWidth.
// → object src `maxWidth` → object src `width`.
let displayPct: string | undefined;
let displayPx: number | undefined;
for (const candidate of [widthProp, maxWidthProp, userSrc.maxWidth]) {
for (const candidate of [widthProp, maxWidthProp, userSrc.maxWidth, userSrc.width]) {
if (candidate === undefined) continue;
const pct = asPercent(candidate);
if (pct) {
Expand Down
34 changes: 34 additions & 0 deletions packages/react/src/components/Image.width-roundtrip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,37 @@ describe("renderToJson row cells default to the Column count", () => {
expect(src.maxWidth).toBe("42.86%");
});
});

describe("object-src width pins identically to the flat width prop (round-trip parity)", () => {
const inOneCol = (img: React.ReactElement) => (
<Email contentWidth="600px">
<Row layout={ColumnLayouts.OneColumn}>
<Column>{img}</Column>
</Row>
</Email>
);

it("src={{ width }} pins (autoWidth:false + percent), not autoWidth:true", () => {
const src = imgSrc(inOneCol(<Image src={{ url: "https://x/p.png", width: 300 } as any} />));
expect(src.autoWidth).toBe(false);
expect(src.maxWidth).toBe("51.72%");
});

it("the documented values={{ src: { width } }} full-control form pins too", () => {
const src = imgSrc(inOneCol(<Image values={{ src: { url: "https://x/p.png", width: 300 } } as any} />));
expect(src.autoWidth).toBe(false);
expect(src.maxWidth).toBe("51.72%");
});

it("src width + height pins and keeps the height for aspect", () => {
const src = imgSrc(inOneCol(<Image src={{ url: "https://x/p.png", width: 300, height: 200 } as any} />));
expect(src.autoWidth).toBe(false);
expect(src.maxWidth).toBe("51.72%");
expect(src.height).toBe(200);
});

it("an explicit autoWidth on src is honored (escape hatch stays responsive)", () => {
const src = imgSrc(inOneCol(<Image src={{ url: "https://x/p.png", width: 300, autoWidth: true } as any} />));
expect(src.autoWidth).toBe(true);
});
});
42 changes: 33 additions & 9 deletions packages/react/src/components/Social.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { SocialExporters, SocialDefaults } from "@unlayer/exporters";
import type { SocialValues, SocialIcon } from "../types";
import type { SocialValues, SocialIcon, SizeInput } from "../types";
import { createItemComponent, type ItemComponentProps } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";

type SocialSemanticProps = SemanticProps<SocialValues> & {
// `iconSize`/`spacing` are pixel counts the exporter does arithmetic on, so they
// must reach it as numbers. Accept a number or a px string for DX and coerce in
// the mapper — a raw "34px" would otherwise render `max-width:NaNpx`.
type SocialSemanticProps = Omit<SemanticProps<SocialValues>, "iconSize" | "spacing"> & {
/** Social icons shorthand (array of {name, url}) */
icons?: SocialIcon[];
/** Icon shape */
iconType?: "circle" | "rounded" | "squared";
/** Icon size in px — a number (34) or px string ("34px"). */
iconSize?: SizeInput;
/** Gap between icons in px — a number or px string. */
spacing?: SizeInput;
};

export interface SocialProps extends Omit<ItemComponentProps<SemanticProps<SocialValues>>, "icons"> {
export interface SocialProps
extends Omit<ItemComponentProps<SemanticProps<SocialValues>>, "icons" | "iconSize" | "spacing"> {
icons?: SocialIcon[] | SocialValues["icons"];
iconType?: "circle" | "rounded" | "squared";
iconSize?: SizeInput;
spacing?: SizeInput;
}

// Defaults from the editor schema
Expand Down Expand Up @@ -46,6 +56,22 @@ const Social = createItemComponent<SocialValues, SocialSemanticProps>({
propMapper: (props) => {
const { icons, iconType, ...rest } = props;

// The exporter does arithmetic on iconSize/spacing (icon box sizing and the
// container max-width), so coerce a px string or number down to a number.
const coerceSizes = (base: Partial<SocialValues>): Partial<SocialValues> => {
for (const key of ["iconSize", "spacing"] as const) {
const v = (base as Record<string, unknown>)[key];
if (typeof v === "string") {
// px count only — a non-px unit ("50%", "1.5em") is invalid here, so
// drop it and let mergeValues fall back to the schema default.
const m = /^(\d+(?:\.\d+)?)(?:px)?$/.exec(v.trim());
if (m) (base as Record<string, unknown>)[key] = parseFloat(m[1]);
else delete (base as Record<string, unknown>)[key];
}
}
return base;
};

// Map shorthand icons array → exporter format
if (Array.isArray(icons)) {
const mapped = icons.map((icon: SocialIcon) => ({
Expand All @@ -61,7 +87,7 @@ const Social = createItemComponent<SocialValues, SocialSemanticProps>({
iconType: iconType ?? base.icons?.iconType ?? "circle",
icons: mapped,
};
return base;
return coerceSizes(base);
}

// If iconType passed without shorthand icons, thread it into nested group
Expand All @@ -72,13 +98,11 @@ const Social = createItemComponent<SocialValues, SocialSemanticProps>({
"Social"
);
base.icons = { ...DEFAULT_ICONS, ...base.icons, iconType };
return base;
return coerceSizes(base);
}

return mapSemanticProps(
props as SemanticProps<SocialValues>,
DEFAULT_VALUES,
"Social"
return coerceSizes(
mapSemanticProps(props as SemanticProps<SocialValues>, DEFAULT_VALUES, "Social")
);
},
displayName: "Social",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ exports[`Render Snapshots > Image > email 1`] = `
<tbody><tr>
<td class="v-text-align" style="padding-right: 0px;padding-left: 0px;" align="center">

<img align="center" border="0" src="https://example.com/img.png" alt="" title="" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 500px;" width="500" height="125" class="v-src-width v-src-max-width">
<img align="center" border="0" src="https://example.com/img.png" alt="" title="" style="outline: none;text-decoration: none;-ms-interpolation-mode: bicubic;clear: both;display: inline-block !important;border: none;height: auto;float: none;width: 100%;max-width: 500px;" width="500" class="v-src-width v-src-max-width">

</td>
</tr>
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/dx-behaviors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ describe("DX: image sizing (a fixed width pins to the editor's canonical percent
expect(src.maxWidth).toBe("62.5%");
});

// Guards the column-overflow regression: a dimensioned image (object src.width
// = natural size) inside a multi-column row must stay responsive, never forced
// to its natural width — which would overflow the narrow column.
it("a dimensioned image stays responsive inside a multi-column row", () => {
// Guards the column-overflow regression: an object-src width inside a narrow
// multi-column row pins (display intent) but clamps to 100% — so it fills the
// column responsively via a percent, never a fixed px that overflows.
it("a dimensioned image in a multi-column row pins but clamps to 100% (no overflow)", () => {
const json: any = renderToJson(
<Body>
<Row cells={[1, 1, 1]}>
Expand All @@ -107,7 +107,7 @@ describe("DX: image sizing (a fixed width pins to the editor's canonical percent
</Body>
);
const src = json.body.rows[0].columns[0].contents[0].values.src;
expect(src.autoWidth).toBe(true);
expect(src.autoWidth).toBe(false);
expect(src.maxWidth).toBe("100%");
expect(src.width).toBe(400);
});
Expand Down
Loading
Loading