@comet/mail-react 9.0.0-beta.2 → 9.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -15
- package/lib/__stories__/examples/CustomBackgroundForFooter.stories.js +32 -0
- package/lib/__stories__/layout-patterns/AsymmetricTwoColumnLayout.stories.d.ts +6 -0
- package/lib/__stories__/layout-patterns/AsymmetricTwoColumnLayout.stories.js +107 -0
- package/lib/__stories__/layout-patterns/SymmetricFourColumnLayout.stories.d.ts +4 -0
- package/lib/__stories__/layout-patterns/SymmetricFourColumnLayout.stories.js +58 -0
- package/lib/__stories__/layout-patterns/SymmetricThreeColumnLayout.stories.d.ts +5 -0
- package/lib/__stories__/layout-patterns/SymmetricThreeColumnLayout.stories.js +104 -0
- package/lib/__stories__/layout-patterns/SymmetricTwoColumnLayout.stories.d.ts +4 -0
- package/lib/__stories__/layout-patterns/SymmetricTwoColumnLayout.stories.js +47 -0
- package/lib/blocks/factories/BlocksBlock.d.ts +1 -1
- package/lib/blocks/factories/OneOfBlock.d.ts +2 -2
- package/lib/blocks/factories/OptionalBlock.d.ts +1 -1
- package/lib/blocks/factories/types.d.ts +1 -1
- package/lib/blocks/pixelImage/HtmlPixelImageBlock.d.ts +11 -0
- package/lib/blocks/pixelImage/HtmlPixelImageBlock.js +27 -0
- package/lib/blocks/pixelImage/MjmlPixelImageBlock.d.ts +9 -0
- package/lib/blocks/pixelImage/MjmlPixelImageBlock.js +24 -0
- package/lib/blocks/pixelImage/__stories__/HtmlPixelImageBlock.stories.d.ts +7 -0
- package/lib/blocks/pixelImage/__stories__/HtmlPixelImageBlock.stories.js +54 -0
- package/lib/blocks/pixelImage/__stories__/MjmlPixelImageBlock.stories.d.ts +7 -0
- package/lib/blocks/pixelImage/__stories__/MjmlPixelImageBlock.stories.js +54 -0
- package/lib/blocks/pixelImage/__stories__/exampleBlockData.d.ts +2 -0
- package/lib/blocks/pixelImage/__stories__/exampleBlockData.js +19 -0
- package/lib/blocks/pixelImage/__tests__/usePixelImageBlockConfig.test.d.ts +1 -0
- package/lib/blocks/pixelImage/__tests__/usePixelImageBlockConfig.test.js +21 -0
- package/lib/blocks/pixelImage/__tests__/usePixelImageBlockData.test.d.ts +1 -0
- package/lib/blocks/pixelImage/__tests__/usePixelImageBlockData.test.js +205 -0
- package/lib/blocks/pixelImage/common.d.ts +18 -0
- package/lib/blocks/pixelImage/common.js +1 -0
- package/lib/blocks/pixelImage/usePixelImageBlockConfig.d.ts +5 -0
- package/lib/blocks/pixelImage/usePixelImageBlockConfig.js +11 -0
- package/lib/blocks/pixelImage/usePixelImageBlockData.d.ts +16 -0
- package/lib/blocks/pixelImage/usePixelImageBlockData.js +80 -0
- package/lib/client/renderMailHtml.d.ts +1 -1
- package/lib/components/mailRoot/MjmlMailRoot.d.ts +13 -4
- package/lib/components/mailRoot/MjmlMailRoot.js +10 -5
- package/lib/components/section/MjmlSection.d.ts +1 -1
- package/lib/components/section/MjmlSection.js +8 -4
- package/lib/components/text/HtmlText.d.ts +1 -1
- package/lib/components/text/textStyles.js +10 -5
- package/lib/components/wrapper/InsideMjmlWrapperContext.d.ts +3 -0
- package/lib/components/wrapper/InsideMjmlWrapperContext.js +6 -0
- package/lib/components/wrapper/MjmlWrapper.d.ts +7 -0
- package/lib/components/wrapper/MjmlWrapper.js +12 -0
- package/lib/components/wrapper/__stories__/MjmlWrapper.stories.d.ts +10 -0
- package/lib/components/wrapper/__stories__/MjmlWrapper.stories.js +36 -0
- package/lib/config/ConfigProvider.d.ts +43 -0
- package/lib/config/ConfigProvider.js +16 -0
- package/lib/config/ConfigProvider.test.d.ts +8 -0
- package/lib/config/ConfigProvider.test.js +30 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +5 -1
- package/lib/server/renderMailHtml.d.ts +1 -1
- package/lib/server/renderMailHtml.test.js +10 -1
- package/lib/storybook/preview.d.ts +34 -0
- package/lib/storybook/preview.js +22 -0
- package/lib/theme/responsiveValue.js +2 -1
- package/package.json +17 -13
- package/lib/__stories__/examples/TextWithImageEmail.stories.js +0 -56
- /package/lib/__stories__/examples/{TextWithImageEmail.stories.d.ts → CustomBackgroundForFooter.stories.d.ts} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlColumn, MjmlRaw } from "@faire/mjml-react";
|
|
3
|
+
import { MjmlMailRoot } from "../../../components/mailRoot/MjmlMailRoot.js";
|
|
4
|
+
import { MjmlSection } from "../../../components/section/MjmlSection.js";
|
|
5
|
+
import { HtmlPixelImageBlock } from "../HtmlPixelImageBlock.js";
|
|
6
|
+
import { exampleBlockData } from "./exampleBlockData.js";
|
|
7
|
+
const config = {
|
|
8
|
+
title: "Components/Blocks/HtmlPixelImageBlock",
|
|
9
|
+
component: HtmlPixelImageBlock,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
parameters: {
|
|
12
|
+
mailRoot: false,
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component: "Renders a pixel-image from the DAM as a raw `<img>` tag, for use in HTML-only emails or inside MJML ending tags such as `MjmlRaw`.\n\n_Note: this story may fail to load the actual image when the API fixtures don't include the referenced DAM file._",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
data: { control: false },
|
|
21
|
+
aspectRatio: {
|
|
22
|
+
control: "select",
|
|
23
|
+
options: ["inherit", "16x9", "4x3", "3x2", "3x1", "2x1", "1x1", "1x2", "1x3", "2x3", "3x4", "9x16"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
data: exampleBlockData,
|
|
28
|
+
width: 600,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export default config;
|
|
32
|
+
export const Default = {
|
|
33
|
+
render: (args) => (_jsx(MjmlMailRoot, { config: {
|
|
34
|
+
pixelImageBlock: {
|
|
35
|
+
validSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560, 3200, 3840],
|
|
36
|
+
baseUrl: "",
|
|
37
|
+
},
|
|
38
|
+
}, children: _jsx(MjmlSection, { indent: true, children: _jsx(MjmlColumn, { children: _jsx(MjmlRaw, { children: _jsx(HtmlPixelImageBlock, { ...args }) }) }) }) })),
|
|
39
|
+
};
|
|
40
|
+
export const AspectRatioOverride = {
|
|
41
|
+
parameters: {
|
|
42
|
+
docs: {
|
|
43
|
+
description: {
|
|
44
|
+
story: "Renders the same DAM image at its native aspect ratio and overridden to `16x9`, demonstrating how the `aspectRatio` prop reframes the rendered image without changing the source DAM record.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
render: (args) => (_jsx(MjmlMailRoot, { config: {
|
|
49
|
+
pixelImageBlock: {
|
|
50
|
+
validSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560, 3200, 3840],
|
|
51
|
+
baseUrl: "",
|
|
52
|
+
},
|
|
53
|
+
}, children: _jsx(MjmlSection, { indent: true, children: _jsx(MjmlColumn, { children: _jsxs(MjmlRaw, { children: [_jsx(HtmlPixelImageBlock, { ...args }), _jsx(HtmlPixelImageBlock, { ...args, aspectRatio: "16x9" })] }) }) }) })),
|
|
54
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { MjmlPixelImageBlock } from "../MjmlPixelImageBlock.js";
|
|
3
|
+
type Story = StoryObj<typeof MjmlPixelImageBlock>;
|
|
4
|
+
declare const config: Meta<typeof MjmlPixelImageBlock>;
|
|
5
|
+
export default config;
|
|
6
|
+
export declare const Default: Story;
|
|
7
|
+
export declare const AspectRatioOverride: Story;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlColumn } from "@faire/mjml-react";
|
|
3
|
+
import { MjmlMailRoot } from "../../../components/mailRoot/MjmlMailRoot.js";
|
|
4
|
+
import { MjmlSection } from "../../../components/section/MjmlSection.js";
|
|
5
|
+
import { MjmlPixelImageBlock } from "../MjmlPixelImageBlock.js";
|
|
6
|
+
import { exampleBlockData } from "./exampleBlockData.js";
|
|
7
|
+
const config = {
|
|
8
|
+
title: "Components/Blocks/MjmlPixelImageBlock",
|
|
9
|
+
component: MjmlPixelImageBlock,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
parameters: {
|
|
12
|
+
mailRoot: false,
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component: "Renders a pixel-image from the DAM as `MjmlImage`. Must be placed within an `MjmlColumn`.\n\n_Note: this story may fail to load the actual image when the API fixtures don't include the referenced DAM file._",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
data: { control: false },
|
|
21
|
+
aspectRatio: {
|
|
22
|
+
control: "select",
|
|
23
|
+
options: ["inherit", "16x9", "4x3", "3x2", "3x1", "2x1", "1x1", "1x2", "1x3", "2x3", "3x4", "9x16"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
args: {
|
|
27
|
+
data: exampleBlockData,
|
|
28
|
+
width: 600,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export default config;
|
|
32
|
+
export const Default = {
|
|
33
|
+
render: (args) => (_jsx(MjmlMailRoot, { config: {
|
|
34
|
+
pixelImageBlock: {
|
|
35
|
+
validSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560, 3200, 3840],
|
|
36
|
+
baseUrl: "",
|
|
37
|
+
},
|
|
38
|
+
}, children: _jsx(MjmlSection, { indent: true, children: _jsx(MjmlColumn, { children: _jsx(MjmlPixelImageBlock, { ...args }) }) }) })),
|
|
39
|
+
};
|
|
40
|
+
export const AspectRatioOverride = {
|
|
41
|
+
parameters: {
|
|
42
|
+
docs: {
|
|
43
|
+
description: {
|
|
44
|
+
story: "Renders the same DAM image at its native aspect ratio and overridden to `16x9`, demonstrating how the `aspectRatio` prop reframes the rendered image without changing the source DAM record.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
render: (args) => (_jsx(MjmlMailRoot, { config: {
|
|
49
|
+
pixelImageBlock: {
|
|
50
|
+
validSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 2560, 3200, 3840],
|
|
51
|
+
baseUrl: "",
|
|
52
|
+
},
|
|
53
|
+
}, children: _jsx(MjmlSection, { indent: true, children: _jsxs(MjmlColumn, { children: [_jsx(MjmlPixelImageBlock, { ...args }), _jsx(MjmlPixelImageBlock, { ...args, aspectRatio: "16x9" })] }) }) })),
|
|
54
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const exampleBlockData = {
|
|
2
|
+
damFile: {
|
|
3
|
+
id: "example-image",
|
|
4
|
+
name: "example-image.jpg",
|
|
5
|
+
size: 1000000,
|
|
6
|
+
mimetype: "image/jpeg",
|
|
7
|
+
contentHash: "example-hash",
|
|
8
|
+
archived: false,
|
|
9
|
+
scope: { domain: "at" },
|
|
10
|
+
fileUrl: "https://picsum.photos/seed/comet-pixel-image/1000/1000",
|
|
11
|
+
image: {
|
|
12
|
+
width: 1000,
|
|
13
|
+
height: 1000,
|
|
14
|
+
cropArea: { focalPoint: "SMART" },
|
|
15
|
+
dominantColor: "#000000",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
urlTemplate: "https://picsum.photos/seed/comet-pixel-image/$resizeWidth/$resizeHeight",
|
|
19
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ConfigProvider } from "../../../config/ConfigProvider.js";
|
|
5
|
+
import { usePixelImageBlockConfig } from "../usePixelImageBlockConfig.js";
|
|
6
|
+
function ConfigProbe() {
|
|
7
|
+
usePixelImageBlockConfig();
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
describe("usePixelImageBlockConfig", () => {
|
|
11
|
+
it("throws when config.pixelImageBlock is unset", () => {
|
|
12
|
+
expect(() => renderToStaticMarkup(_jsx(ConfigProbe, {}))).toThrowError(/`pixelImageBlock` must be set/);
|
|
13
|
+
});
|
|
14
|
+
it("error message points at MjmlMailRoot and ConfigProvider", () => {
|
|
15
|
+
expect(() => renderToStaticMarkup(_jsx(ConfigProbe, {}))).toThrowError(/MjmlMailRoot/);
|
|
16
|
+
expect(() => renderToStaticMarkup(_jsx(ConfigProbe, {}))).toThrowError(/ConfigProvider/);
|
|
17
|
+
});
|
|
18
|
+
it("does not throw when config.pixelImageBlock is provided", () => {
|
|
19
|
+
expect(() => renderToStaticMarkup(_jsx(ConfigProvider, { config: { pixelImageBlock: { validSizes: [640, 1280], baseUrl: "http://localhost:3000" } }, children: _jsx(ConfigProbe, {}) }))).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ConfigProvider } from "../../../config/ConfigProvider.js";
|
|
5
|
+
import { createTheme } from "../../../theme/createTheme.js";
|
|
6
|
+
import { ThemeProvider } from "../../../theme/ThemeProvider.js";
|
|
7
|
+
import { usePixelImageBlockData } from "../usePixelImageBlockData.js";
|
|
8
|
+
function captureUsePixelImageData({ data, defaultRenderWidth, largestPossibleRenderWidth, aspectRatio, config }) {
|
|
9
|
+
const captured = { value: undefined };
|
|
10
|
+
function Probe() {
|
|
11
|
+
captured.value = usePixelImageBlockData({ data, defaultRenderWidth, largestPossibleRenderWidth, aspectRatio });
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
renderToStaticMarkup(_jsx(ThemeProvider, { theme: createTheme(), children: _jsx(ConfigProvider, { config: config, children: _jsx(Probe, {}) }) }));
|
|
15
|
+
if (captured.value === undefined) {
|
|
16
|
+
throw new Error("Probe did not run");
|
|
17
|
+
}
|
|
18
|
+
return captured.value;
|
|
19
|
+
}
|
|
20
|
+
function expectNonNull(value) {
|
|
21
|
+
if (value === null) {
|
|
22
|
+
throw new Error("Expected non-null result from usePixelImageBlockData");
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
const validSizes = [320, 640, 1280, 2048];
|
|
27
|
+
const baseUrl = "http://localhost:3000";
|
|
28
|
+
const config = { pixelImageBlock: { validSizes, baseUrl } };
|
|
29
|
+
const smartUrlTemplate = "/dam/images/abc/resize:$resizeWidth:$resizeHeight/photo.jpg";
|
|
30
|
+
const smartImageData = {
|
|
31
|
+
damFile: {
|
|
32
|
+
id: "id-1",
|
|
33
|
+
name: "photo.jpg",
|
|
34
|
+
size: 1000,
|
|
35
|
+
mimetype: "image/jpeg",
|
|
36
|
+
contentHash: "hash",
|
|
37
|
+
archived: false,
|
|
38
|
+
fileUrl: "/dam/files/id-1.jpg",
|
|
39
|
+
image: {
|
|
40
|
+
width: 4000,
|
|
41
|
+
height: 2000,
|
|
42
|
+
cropArea: { focalPoint: "SMART" },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
urlTemplate: smartUrlTemplate,
|
|
46
|
+
};
|
|
47
|
+
describe("usePixelImageBlockData — width selection", () => {
|
|
48
|
+
it("picks the smallest validSizes entry >= defaultRenderWidth × 2", () => {
|
|
49
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, config }));
|
|
50
|
+
expect(result.imageUrl).toContain(":640:");
|
|
51
|
+
});
|
|
52
|
+
it("picks an exact validSizes match when present", () => {
|
|
53
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 320, config }));
|
|
54
|
+
expect(result.imageUrl).toContain(":640:");
|
|
55
|
+
});
|
|
56
|
+
it("falls back to the largest validSizes entry when none qualifies", () => {
|
|
57
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 2000, config }));
|
|
58
|
+
expect(result.imageUrl).toContain(":2048:");
|
|
59
|
+
});
|
|
60
|
+
it("uses largestPossibleRenderWidth × 2 when defaultRenderWidth equals largestPossibleRenderWidth", () => {
|
|
61
|
+
const result = expectNonNull(captureUsePixelImageData({
|
|
62
|
+
data: smartImageData,
|
|
63
|
+
defaultRenderWidth: 600,
|
|
64
|
+
largestPossibleRenderWidth: 600,
|
|
65
|
+
config,
|
|
66
|
+
}));
|
|
67
|
+
expect(result.imageUrl).toContain(":1200:");
|
|
68
|
+
});
|
|
69
|
+
it("defaults largestPossibleRenderWidth to theme.sizes.bodyWidth (600) and triggers DPR-2 fixed path at width 600", () => {
|
|
70
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 600, config }));
|
|
71
|
+
expect(result.imageUrl).toContain(":1200:");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("usePixelImageBlockData — URL prefixing", () => {
|
|
75
|
+
it("prefixes a relative URL with config.pixelImageBlock.baseUrl", () => {
|
|
76
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, config }));
|
|
77
|
+
expect(result.imageUrl.startsWith(`${baseUrl}/dam/images/abc/`)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it("does not prefix an absolute URL", () => {
|
|
80
|
+
const absoluteData = {
|
|
81
|
+
...smartImageData,
|
|
82
|
+
urlTemplate: "https://cdn.example.com/$resizeWidth/$resizeHeight/photo.jpg",
|
|
83
|
+
};
|
|
84
|
+
const result = expectNonNull(captureUsePixelImageData({ data: absoluteData, defaultRenderWidth: 200, config }));
|
|
85
|
+
expect(result.imageUrl.startsWith("https://cdn.example.com/")).toBe(true);
|
|
86
|
+
expect(result.imageUrl.includes(baseUrl)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("usePixelImageBlockData — aspect ratio", () => {
|
|
90
|
+
it("uses the natural image aspect ratio for SMART crop areas", () => {
|
|
91
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, config }));
|
|
92
|
+
expect(result.desktopImageHeight).toBe(100);
|
|
93
|
+
expect(result.imageUrl).toContain(":640:320/");
|
|
94
|
+
});
|
|
95
|
+
it("derives aspect ratio from crop dimensions for non-SMART crop areas", () => {
|
|
96
|
+
const nonSmartData = {
|
|
97
|
+
damFile: {
|
|
98
|
+
id: "id-2",
|
|
99
|
+
name: "photo2.jpg",
|
|
100
|
+
size: 1000,
|
|
101
|
+
mimetype: "image/jpeg",
|
|
102
|
+
contentHash: "hash",
|
|
103
|
+
archived: false,
|
|
104
|
+
fileUrl: "/dam/files/id-2.jpg",
|
|
105
|
+
image: {
|
|
106
|
+
width: 4000,
|
|
107
|
+
height: 2000,
|
|
108
|
+
cropArea: { focalPoint: "CENTER", width: 50, height: 100, x: 25, y: 0 },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
urlTemplate: smartUrlTemplate,
|
|
112
|
+
};
|
|
113
|
+
const result = expectNonNull(captureUsePixelImageData({ data: nonSmartData, defaultRenderWidth: 200, config }));
|
|
114
|
+
expect(result.desktopImageHeight).toBe(200);
|
|
115
|
+
expect(result.imageUrl).toContain(":640:640/");
|
|
116
|
+
});
|
|
117
|
+
it("throws when crop dimensions are missing on a non-SMART crop area", () => {
|
|
118
|
+
const malformedData = {
|
|
119
|
+
damFile: {
|
|
120
|
+
id: "id-3",
|
|
121
|
+
name: "photo3.jpg",
|
|
122
|
+
size: 1000,
|
|
123
|
+
mimetype: "image/jpeg",
|
|
124
|
+
contentHash: "hash",
|
|
125
|
+
archived: false,
|
|
126
|
+
fileUrl: "/dam/files/id-3.jpg",
|
|
127
|
+
image: {
|
|
128
|
+
width: 4000,
|
|
129
|
+
height: 2000,
|
|
130
|
+
cropArea: { focalPoint: "CENTER" },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
urlTemplate: smartUrlTemplate,
|
|
134
|
+
};
|
|
135
|
+
expect(() => captureUsePixelImageData({ data: malformedData, defaultRenderWidth: 200, config })).toThrow(/Missing crop dimensions/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe("usePixelImageBlockData — aspectRatio override", () => {
|
|
139
|
+
it("uses a numeric aspectRatio in place of the cropArea-derived ratio", () => {
|
|
140
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, aspectRatio: 16 / 9, config }));
|
|
141
|
+
expect(result.imageUrl).toContain(":640:360/");
|
|
142
|
+
expect(result.desktopImageHeight).toBe(113);
|
|
143
|
+
});
|
|
144
|
+
it.each([
|
|
145
|
+
["WxH", "16x9"],
|
|
146
|
+
["W:H", "16:9"],
|
|
147
|
+
["W/H", "16/9"],
|
|
148
|
+
])("parses string aspectRatio in %s form", (_label, value) => {
|
|
149
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, aspectRatio: value, config }));
|
|
150
|
+
expect(result.imageUrl).toContain(":640:360/");
|
|
151
|
+
expect(result.desktopImageHeight).toBe(113);
|
|
152
|
+
});
|
|
153
|
+
it("treats a single-token string as `width / 1`", () => {
|
|
154
|
+
const result = expectNonNull(captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, aspectRatio: "2", config }));
|
|
155
|
+
expect(result.imageUrl).toContain(":640:320/");
|
|
156
|
+
expect(result.desktopImageHeight).toBe(100);
|
|
157
|
+
});
|
|
158
|
+
it("overrides a non-SMART cropArea ratio", () => {
|
|
159
|
+
const nonSmartData = {
|
|
160
|
+
damFile: {
|
|
161
|
+
id: "id-override",
|
|
162
|
+
name: "photo.jpg",
|
|
163
|
+
size: 1000,
|
|
164
|
+
mimetype: "image/jpeg",
|
|
165
|
+
contentHash: "hash",
|
|
166
|
+
archived: false,
|
|
167
|
+
fileUrl: "/dam/files/id-override.jpg",
|
|
168
|
+
image: {
|
|
169
|
+
width: 4000,
|
|
170
|
+
height: 2000,
|
|
171
|
+
cropArea: { focalPoint: "CENTER", width: 50, height: 100, x: 25, y: 0 },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
urlTemplate: smartUrlTemplate,
|
|
175
|
+
};
|
|
176
|
+
const result = expectNonNull(captureUsePixelImageData({ data: nonSmartData, defaultRenderWidth: 200, aspectRatio: "16x9", config }));
|
|
177
|
+
expect(result.imageUrl).toContain(":640:360/");
|
|
178
|
+
expect(result.desktopImageHeight).toBe(113);
|
|
179
|
+
});
|
|
180
|
+
it("throws when the aspectRatio string is malformed", () => {
|
|
181
|
+
expect(() => captureUsePixelImageData({ data: smartImageData, defaultRenderWidth: 200, aspectRatio: "not-a-ratio", config })).toThrow(/An error occurred while parsing the aspect ratio: not-a-ratio/);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
describe("usePixelImageBlockData — incomplete data", () => {
|
|
185
|
+
it("returns null when damFile is absent", () => {
|
|
186
|
+
const result = captureUsePixelImageData({ data: { urlTemplate: smartUrlTemplate }, defaultRenderWidth: 200, config });
|
|
187
|
+
expect(result).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
it("returns null when damFile.image is absent", () => {
|
|
190
|
+
const noImageData = {
|
|
191
|
+
damFile: {
|
|
192
|
+
id: "id-4",
|
|
193
|
+
name: "no-image.jpg",
|
|
194
|
+
size: 0,
|
|
195
|
+
mimetype: "image/jpeg",
|
|
196
|
+
contentHash: "hash",
|
|
197
|
+
archived: false,
|
|
198
|
+
fileUrl: "/dam/files/id-4.jpg",
|
|
199
|
+
},
|
|
200
|
+
urlTemplate: smartUrlTemplate,
|
|
201
|
+
};
|
|
202
|
+
const result = captureUsePixelImageData({ data: noImageData, defaultRenderWidth: 200, config });
|
|
203
|
+
expect(result).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PixelImageBlockData } from "../../blocks.generated.js";
|
|
2
|
+
export type PixelImageBlockBaseProps = {
|
|
3
|
+
/** The block data to render. */
|
|
4
|
+
data: PixelImageBlockData;
|
|
5
|
+
/** Width at which the image is rendered, in the default/desktop breakpoint. */
|
|
6
|
+
width: number;
|
|
7
|
+
/**
|
|
8
|
+
* Largest possible width the image can be rendered at across breakpoints.
|
|
9
|
+
* Defaults to `theme.sizes.bodyWidth`. Use this when the image can stretch
|
|
10
|
+
* wider on a narrower breakpoint than its desktop render width.
|
|
11
|
+
*/
|
|
12
|
+
largestPossibleRenderWidth?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Aspect ratio for the rendered image.
|
|
15
|
+
* @example "16x9"
|
|
16
|
+
*/
|
|
17
|
+
aspectRatio?: number | string;
|
|
18
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useConfig } from "../../config/ConfigProvider.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reads `config.pixelImageBlock` from the configuration context and returns it narrowed to non-null.
|
|
4
|
+
*/
|
|
5
|
+
export function usePixelImageBlockConfig() {
|
|
6
|
+
const { pixelImageBlock } = useConfig();
|
|
7
|
+
if (!pixelImageBlock) {
|
|
8
|
+
throw new Error("`pixelImageBlock` must be set in `config` on `MjmlMailRoot` or `ConfigProvider` to use the pixel-image configuration.");
|
|
9
|
+
}
|
|
10
|
+
return pixelImageBlock;
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PixelImageBlockData as PixelImageBlockSourceData } from "../../blocks.generated.js";
|
|
2
|
+
interface UsePixelImageBlockDataProps {
|
|
3
|
+
data: PixelImageBlockSourceData;
|
|
4
|
+
defaultRenderWidth: number;
|
|
5
|
+
largestPossibleRenderWidth?: number;
|
|
6
|
+
aspectRatio?: number | string;
|
|
7
|
+
}
|
|
8
|
+
interface PixelImageBlockData {
|
|
9
|
+
imageUrl: string;
|
|
10
|
+
defaultRenderWidth: number;
|
|
11
|
+
desktopImageHeight: number;
|
|
12
|
+
alt: string | undefined;
|
|
13
|
+
title: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare function usePixelImageBlockData({ data: { damFile, cropArea, urlTemplate }, defaultRenderWidth, largestPossibleRenderWidth: passedLargestPossibleRenderWidth, aspectRatio: passedAspectRatio, }: UsePixelImageBlockDataProps): PixelImageBlockData | null;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useTheme } from "../../theme/ThemeProvider.js";
|
|
2
|
+
import { usePixelImageBlockConfig } from "./usePixelImageBlockConfig.js";
|
|
3
|
+
export function usePixelImageBlockData({ data: { damFile, cropArea, urlTemplate }, defaultRenderWidth, largestPossibleRenderWidth: passedLargestPossibleRenderWidth, aspectRatio: passedAspectRatio, }) {
|
|
4
|
+
const theme = useTheme();
|
|
5
|
+
const { validSizes, baseUrl } = usePixelImageBlockConfig();
|
|
6
|
+
const largestPossibleRenderWidth = passedLargestPossibleRenderWidth ?? theme.sizes.bodyWidth;
|
|
7
|
+
if (!damFile?.image) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const usedCropArea = cropArea ?? damFile.image.cropArea;
|
|
11
|
+
const aspectRatio = passedAspectRatio !== undefined ? parseAspectRatio(passedAspectRatio) : calculateAspectRatio(damFile.image, usedCropArea);
|
|
12
|
+
const optimalWidth = getOptimalAllowedImageWidth(validSizes, defaultRenderWidth, largestPossibleRenderWidth);
|
|
13
|
+
const resolvedImageUrl = generateImageUrl(urlTemplate, optimalWidth, aspectRatio);
|
|
14
|
+
return {
|
|
15
|
+
imageUrl: isAbsoluteUrl(resolvedImageUrl) ? resolvedImageUrl : `${baseUrl}${resolvedImageUrl}`,
|
|
16
|
+
defaultRenderWidth,
|
|
17
|
+
desktopImageHeight: Math.round(defaultRenderWidth / aspectRatio),
|
|
18
|
+
alt: damFile.altText,
|
|
19
|
+
title: damFile.title,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function isAbsoluteUrl(url) {
|
|
23
|
+
return !url.startsWith("/");
|
|
24
|
+
}
|
|
25
|
+
function getOptimalAllowedImageWidth(validSizes, defaultRenderWidth, largestPossibleRenderWidth) {
|
|
26
|
+
const sortedValidSizes = validSizes.sort((a, b) => a - b);
|
|
27
|
+
let width = null;
|
|
28
|
+
const largestPossibleWidth = sortedValidSizes[sortedValidSizes.length - 1];
|
|
29
|
+
sortedValidSizes.forEach((validWidth) => {
|
|
30
|
+
if (defaultRenderWidth === largestPossibleRenderWidth) {
|
|
31
|
+
width = largestPossibleRenderWidth * 2;
|
|
32
|
+
}
|
|
33
|
+
else if (!width && validWidth >= defaultRenderWidth * 2) {
|
|
34
|
+
width = validWidth;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
if (!width) {
|
|
38
|
+
return largestPossibleWidth;
|
|
39
|
+
}
|
|
40
|
+
return width;
|
|
41
|
+
}
|
|
42
|
+
// Copied from `calculateInheritAspectRatio` in `@comet/site-react` (`src/image/image.utils.ts`).
|
|
43
|
+
// Keep in sync with the site-react version when changes are made.
|
|
44
|
+
function calculateAspectRatio(image, cropArea) {
|
|
45
|
+
if (cropArea.focalPoint === "SMART") {
|
|
46
|
+
return image.width / image.height;
|
|
47
|
+
}
|
|
48
|
+
if (cropArea.width === undefined || cropArea.height === undefined) {
|
|
49
|
+
throw new Error("Missing crop dimensions");
|
|
50
|
+
}
|
|
51
|
+
return (cropArea.width * image.width) / (cropArea.height * image.height);
|
|
52
|
+
}
|
|
53
|
+
// Copied from `generateImageUrl` in `@comet/site-react` (`src/image/image.utils.ts`).
|
|
54
|
+
// Keep in sync with the site-react version when changes are made.
|
|
55
|
+
function generateImageUrl(urlTemplate, width, aspectRatio) {
|
|
56
|
+
return urlTemplate.replace("$resizeWidth", String(width)).replace("$resizeHeight", String(Math.ceil(width / aspectRatio)));
|
|
57
|
+
}
|
|
58
|
+
// Copied from `parseAspectRatio` in `@comet/site-react` (`src/image/image.utils.ts`).
|
|
59
|
+
// Keep in sync with the site-react version when changes are made.
|
|
60
|
+
function parseAspectRatio(value) {
|
|
61
|
+
let width;
|
|
62
|
+
let height;
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
[width, height] = value.split(/[x/:]/).map((part) => {
|
|
65
|
+
const parsed = parseFloat(part);
|
|
66
|
+
return isNaN(parsed) ? undefined : parsed;
|
|
67
|
+
});
|
|
68
|
+
if (width && !height) {
|
|
69
|
+
height = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
width = value;
|
|
74
|
+
height = 1;
|
|
75
|
+
}
|
|
76
|
+
if (!width || !height) {
|
|
77
|
+
throw new Error(`An error occurred while parsing the aspect ratio: ${value}`);
|
|
78
|
+
}
|
|
79
|
+
return width / height;
|
|
80
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mjml2html from "mjml-browser";
|
|
2
|
-
import {
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
3
|
type MjmlOptions = Parameters<typeof mjml2html>[1];
|
|
4
4
|
type MjmlWarning = ReturnType<typeof mjml2html>["errors"][number];
|
|
5
5
|
export declare function renderMailHtml(element: ReactElement, options?: MjmlOptions): {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PropsWithChildren, ReactNode } from "react";
|
|
2
|
+
import { type Config } from "../../config/ConfigProvider.js";
|
|
2
3
|
import type { Theme } from "../../theme/themeTypes.js";
|
|
3
4
|
type MjmlMailRootProps = PropsWithChildren<{
|
|
4
5
|
/**
|
|
@@ -6,17 +7,25 @@ type MjmlMailRootProps = PropsWithChildren<{
|
|
|
6
7
|
* (equivalent to `createTheme()`) is used.
|
|
7
8
|
*/
|
|
8
9
|
theme?: Theme;
|
|
10
|
+
/** Extra content appended inside the built-in `<MjmlAttributes>`, after the default `<MjmlAll>`. */
|
|
11
|
+
attributes?: ReactNode;
|
|
12
|
+
/** Extra content appended inside `<MjmlHead>`, after the registered styles block. */
|
|
13
|
+
head?: ReactNode;
|
|
14
|
+
/**
|
|
15
|
+
* Configuration to make available to descendants via `useConfig`.
|
|
16
|
+
*/
|
|
17
|
+
config?: Config;
|
|
9
18
|
}>;
|
|
10
19
|
/**
|
|
11
20
|
* The root element for email templates. Renders the standard MJML email skeleton
|
|
12
21
|
* (`<Mjml>`, `<MjmlHead>`, `<MjmlBody>`) with `<MjmlAll padding={0} />` as the
|
|
13
22
|
* default attribute so all components start with zero padding.
|
|
14
23
|
*
|
|
15
|
-
* Accepts an optional `theme` prop that controls the body width and responsive
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
24
|
+
* Accepts an optional `theme` prop that controls the body width and responsive breakpoints. The theme is made available to all descendant components via `useTheme()`.
|
|
25
|
+
*
|
|
26
|
+
* Accepts an optional `config` prop containing configuration. When provided, the value is made available to descendants via `useConfig()`.
|
|
18
27
|
*
|
|
19
28
|
* Direct children should be section-level components (e.g. `MjmlSection`).
|
|
20
29
|
*/
|
|
21
|
-
export declare function MjmlMailRoot({ theme: themeProp, children }: MjmlMailRootProps): ReactNode;
|
|
30
|
+
export declare function MjmlMailRoot({ theme: themeProp, attributes, head, config, children }: MjmlMailRootProps): ReactNode;
|
|
22
31
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Mjml, MjmlAll, MjmlAttributes, MjmlBody, MjmlBreakpoint, MjmlHead } from "@faire/mjml-react";
|
|
3
|
+
import { ConfigProvider } from "../../config/ConfigProvider.js";
|
|
3
4
|
import { Styles } from "../../styles/Styles.js";
|
|
4
5
|
import { createTheme } from "../../theme/createTheme.js";
|
|
5
6
|
import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
@@ -8,13 +9,17 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
|
8
9
|
* (`<Mjml>`, `<MjmlHead>`, `<MjmlBody>`) with `<MjmlAll padding={0} />` as the
|
|
9
10
|
* default attribute so all components start with zero padding.
|
|
10
11
|
*
|
|
11
|
-
* Accepts an optional `theme` prop that controls the body width and responsive
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
12
|
+
* Accepts an optional `theme` prop that controls the body width and responsive breakpoints. The theme is made available to all descendant components via `useTheme()`.
|
|
13
|
+
*
|
|
14
|
+
* Accepts an optional `config` prop containing configuration. When provided, the value is made available to descendants via `useConfig()`.
|
|
14
15
|
*
|
|
15
16
|
* Direct children should be section-level components (e.g. `MjmlSection`).
|
|
16
17
|
*/
|
|
17
|
-
export function MjmlMailRoot({ theme: themeProp, children }) {
|
|
18
|
+
export function MjmlMailRoot({ theme: themeProp, attributes, head, config, children }) {
|
|
18
19
|
const theme = themeProp ?? createTheme();
|
|
19
|
-
|
|
20
|
+
const content = (_jsx(ThemeProvider, { theme: theme, children: _jsxs(Mjml, { children: [_jsxs(MjmlHead, { children: [_jsxs(MjmlAttributes, { children: [_jsx(MjmlAll, { padding: "0", fontFamily: theme.text.fontFamily }), attributes] }), _jsx(MjmlBreakpoint, { width: `${theme.breakpoints.mobile.value}px` }), _jsx(Styles, {}), head] }), _jsx(MjmlBody, { width: theme.sizes.bodyWidth, backgroundColor: theme.colors.background.body, children: children })] }) }));
|
|
21
|
+
if (config) {
|
|
22
|
+
return _jsx(ConfigProvider, { config: config, children: content });
|
|
23
|
+
}
|
|
24
|
+
return content;
|
|
20
25
|
}
|
|
@@ -11,5 +11,5 @@ export type MjmlSectionProps = IMjmlSectionProps & {
|
|
|
11
11
|
group?: Partial<IMjmlGroupProps>;
|
|
12
12
|
};
|
|
13
13
|
};
|
|
14
|
-
/** A section wrapper for email layouts. Must be a direct child of `MjmlBody`. */
|
|
14
|
+
/** A section wrapper for email layouts. Must be a direct child of `MjmlBody` or `MjmlWrapper`. */
|
|
15
15
|
export declare function MjmlSection({ children, indent, disableResponsiveBehavior, slotProps, className, ...restProps }: MjmlSectionProps): ReactNode;
|
|
@@ -5,12 +5,14 @@ import { registerStyles } from "../../styles/registerStyles.js";
|
|
|
5
5
|
import { getDefaultFromResponsiveValue, getResponsiveOverrides } from "../../theme/responsiveValue.js";
|
|
6
6
|
import { useOptionalTheme } from "../../theme/ThemeProvider.js";
|
|
7
7
|
import { css } from "../../utils/css.js";
|
|
8
|
-
|
|
8
|
+
import { useIsInsideMjmlWrapper } from "../wrapper/InsideMjmlWrapperContext.js";
|
|
9
|
+
/** A section wrapper for email layouts. Must be a direct child of `MjmlBody` or `MjmlWrapper`. */
|
|
9
10
|
export function MjmlSection({ children, indent, disableResponsiveBehavior, slotProps, className, ...restProps }) {
|
|
10
11
|
const theme = useOptionalTheme();
|
|
12
|
+
const isInsideWrapper = useIsInsideMjmlWrapper();
|
|
11
13
|
const indentProps = indent ? getIndentProps(theme) : {};
|
|
12
14
|
const resolvedClassName = clsx("mjmlSection", indent && "mjmlSection--indented", className);
|
|
13
|
-
const themeBackgroundProps = theme ? { backgroundColor: theme.colors.background.content } : {};
|
|
15
|
+
const themeBackgroundProps = theme && !isInsideWrapper ? { backgroundColor: theme.colors.background.content } : {};
|
|
14
16
|
return (_jsx(BaseMjmlSection, { className: resolvedClassName, ...themeBackgroundProps, ...indentProps, ...restProps, children: disableResponsiveBehavior ? _jsx(MjmlGroup, { ...slotProps?.group, children: children }) : _jsx(_Fragment, { children: children }) }));
|
|
15
17
|
}
|
|
16
18
|
function getIndentProps(theme) {
|
|
@@ -24,13 +26,15 @@ function getIndentProps(theme) {
|
|
|
24
26
|
}
|
|
25
27
|
registerStyles((theme) => {
|
|
26
28
|
const overrides = getResponsiveOverrides(theme.sizes.contentIndentation);
|
|
27
|
-
if (overrides.length === 0)
|
|
29
|
+
if (overrides.length === 0) {
|
|
28
30
|
return css ``;
|
|
31
|
+
}
|
|
29
32
|
return overrides
|
|
30
33
|
.map((override) => {
|
|
31
34
|
const breakpoint = theme.breakpoints[override.breakpointKey];
|
|
32
|
-
if (!breakpoint)
|
|
35
|
+
if (!breakpoint) {
|
|
33
36
|
return "";
|
|
37
|
+
}
|
|
34
38
|
return css `
|
|
35
39
|
${breakpoint.belowMediaQuery} {
|
|
36
40
|
.mjmlSection--indented > table > tbody > tr > td {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ComponentPropsWithoutRef, JSX, ReactNode, TdHTMLAttributes } from "react";
|
|
2
2
|
import type { VariantName } from "../../theme/themeTypes.js";
|
|
3
3
|
interface HtmlTextOwnProps {
|
|
4
4
|
/**
|