@unlayer/react-elements 0.1.15 → 0.1.17
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 +20 -2
- package/dist/index.cjs +188 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -18
- package/dist/index.d.ts +47 -18
- package/dist/index.js +188 -26
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -47,6 +47,16 @@ function WelcomeEmail() {
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Using with AI agents (Claude, Cursor, etc.)
|
|
51
|
+
|
|
52
|
+
Assembling these templates in code is something AI coding agents do well. The official **`unlayer-elements`** skill teaches them the strict structure, the canonical prop shapes, the image-sizing model, and the patterns that make generated emails actually look good:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx skills add unlayer/unlayer-skills
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This installs the `unlayer-elements` skill (alongside the other Unlayer skills). Your agent then knows how to build emails, pages, and documents with these components and render them via `renderToHtml` / `renderToJson`. See [unlayer/unlayer-skills](https://github.com/unlayer/unlayer-skills).
|
|
59
|
+
|
|
50
60
|
## Critical Rules
|
|
51
61
|
|
|
52
62
|
These props have non-obvious shapes that **must** be followed exactly:
|
|
@@ -293,7 +303,7 @@ Each wrapper threads the correct mode to all children automatically.
|
|
|
293
303
|
|
|
294
304
|
## renderToHtml
|
|
295
305
|
|
|
296
|
-
Render any element tree to a
|
|
306
|
+
Render any element tree to a complete, ready-to-use HTML document — from `<!DOCTYPE ...>` to `</html>` — with no React hydration markers. Perfect for email sending and PDF generation:
|
|
297
307
|
|
|
298
308
|
```tsx
|
|
299
309
|
import { renderToHtml, Email, Row, Column, ColumnLayouts, Paragraph, Button } from '@unlayer/react-elements';
|
|
@@ -311,10 +321,18 @@ const html = renderToHtml(
|
|
|
311
321
|
</Button>
|
|
312
322
|
</Column>
|
|
313
323
|
</Row>
|
|
314
|
-
</Email
|
|
324
|
+
</Email>,
|
|
325
|
+
{ title: "Welcome" }
|
|
315
326
|
);
|
|
316
327
|
```
|
|
317
328
|
|
|
329
|
+
The document shell matches the mode: `<Email>` gets the XHTML transitional doctype plus the MSO conditional comments Outlook needs, `<Page>` gets a standard HTML5 shell, and `<Document>` gets a print/PDF-friendly XHTML shell. Options accept config overrides plus:
|
|
330
|
+
|
|
331
|
+
- `title` — document `<title>`
|
|
332
|
+
- `fonts` — web-font stylesheet URLs to load via `<link>` tags, e.g. `[{ url: "https://fonts.googleapis.com/css2?family=Inter" }]`
|
|
333
|
+
|
|
334
|
+
If your app owns the document shell (an existing page template, an ESP template, an iframe `srcdoc`), use `renderToHtmlParts` instead — it returns `{ head, body }` chunks for you to place yourself.
|
|
335
|
+
|
|
318
336
|
## UnlayerProvider
|
|
319
337
|
|
|
320
338
|
Configure global settings like CDN base URL, merge tags, text direction, and rendering mode:
|
package/dist/index.cjs
CHANGED
|
@@ -198,6 +198,50 @@ function textNodeToHtml(node) {
|
|
|
198
198
|
}
|
|
199
199
|
return html;
|
|
200
200
|
}
|
|
201
|
+
var NUMERIC_ALIGN = {
|
|
202
|
+
1: "left",
|
|
203
|
+
2: "center",
|
|
204
|
+
3: "right",
|
|
205
|
+
4: "justify"
|
|
206
|
+
};
|
|
207
|
+
function dirAttribute(direction) {
|
|
208
|
+
return direction === "ltr" || direction === "rtl" ? ` dir="${direction}"` : "";
|
|
209
|
+
}
|
|
210
|
+
function paragraphAlign(format) {
|
|
211
|
+
if (typeof format === "string") {
|
|
212
|
+
return ["left", "center", "right", "justify"].includes(format) ? format : void 0;
|
|
213
|
+
}
|
|
214
|
+
if (typeof format === "number") {
|
|
215
|
+
return NUMERIC_ALIGN[format];
|
|
216
|
+
}
|
|
217
|
+
return void 0;
|
|
218
|
+
}
|
|
219
|
+
function paragraphNodeToHtml(node, childrenHtml) {
|
|
220
|
+
const isInlineTool = !!node.isInlineTool;
|
|
221
|
+
const tag = isInlineTool ? "span" : "p";
|
|
222
|
+
const dir = dirAttribute(node.direction);
|
|
223
|
+
const styleParts = [];
|
|
224
|
+
if (node.style) {
|
|
225
|
+
styleParts.push(String(node.style).replace(/;+\s*$/, ""));
|
|
226
|
+
}
|
|
227
|
+
if (!isInlineTool) {
|
|
228
|
+
styleParts.push(
|
|
229
|
+
"margin: 0px",
|
|
230
|
+
"margin-block-start: 0px",
|
|
231
|
+
"margin-block-end: 0px"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const indent = Number(node.indent) || 0;
|
|
235
|
+
if (indent > 0) {
|
|
236
|
+
styleParts.push(`padding-inline-start: ${indent * 40}px`);
|
|
237
|
+
}
|
|
238
|
+
const align = paragraphAlign(node.format);
|
|
239
|
+
if (align) {
|
|
240
|
+
styleParts.push(`text-align: ${align}`);
|
|
241
|
+
}
|
|
242
|
+
const style = styleParts.length > 0 ? ` style="${escapeHtml(styleParts.join("; ") + ";")}"` : "";
|
|
243
|
+
return `<${tag}${dir}${style}>${childrenHtml || "<br>"}</${tag}>`;
|
|
244
|
+
}
|
|
201
245
|
function nodeToHtml(node) {
|
|
202
246
|
if (!node || typeof node !== "object") return "";
|
|
203
247
|
const type = node.type;
|
|
@@ -212,14 +256,11 @@ function nodeToHtml(node) {
|
|
|
212
256
|
case "root":
|
|
213
257
|
return childrenHtml;
|
|
214
258
|
case "paragraph":
|
|
215
|
-
case "extended-paragraph":
|
|
216
|
-
|
|
217
|
-
const dir = node.direction ? ` dir="${node.direction}"` : "";
|
|
218
|
-
return `<p${dir}>${childrenHtml}</p>`;
|
|
219
|
-
}
|
|
259
|
+
case "extended-paragraph":
|
|
260
|
+
return paragraphNodeToHtml(node, childrenHtml);
|
|
220
261
|
case "heading": {
|
|
221
|
-
const tag = node.tag
|
|
222
|
-
const dir = node.direction
|
|
262
|
+
const tag = /^h[1-6]$/.test(node.tag) ? node.tag : "h1";
|
|
263
|
+
const dir = dirAttribute(node.direction);
|
|
223
264
|
return `<${tag}${dir}>${childrenHtml}</${tag}>`;
|
|
224
265
|
}
|
|
225
266
|
case "link": {
|
|
@@ -415,6 +456,16 @@ function mapSemanticProps(props, defaultValues, componentType) {
|
|
|
415
456
|
if (Object.keys(collected).length > 0) {
|
|
416
457
|
final.border = { ...final.border || {}, ...collected };
|
|
417
458
|
}
|
|
459
|
+
if (final.border && typeof final.border === "object") {
|
|
460
|
+
const b = { ...final.border };
|
|
461
|
+
for (const key of Object.keys(b)) {
|
|
462
|
+
if (!/Width$/.test(key)) continue;
|
|
463
|
+
const v = b[key];
|
|
464
|
+
if (typeof v === "number") b[key] = `${v}px`;
|
|
465
|
+
else if (typeof v === "string" && /^\d+(?:\.\d+)?$/.test(v.trim())) b[key] = `${v.trim()}px`;
|
|
466
|
+
}
|
|
467
|
+
final.border = b;
|
|
468
|
+
}
|
|
418
469
|
}
|
|
419
470
|
return final;
|
|
420
471
|
}
|
|
@@ -1112,7 +1163,7 @@ function getWidthPercentages(cells) {
|
|
|
1112
1163
|
return { value, className };
|
|
1113
1164
|
});
|
|
1114
1165
|
}
|
|
1115
|
-
function generateGridCSS(cells, mode, contentWidth = 600, mobileBreakpoint =
|
|
1166
|
+
function generateGridCSS(cells, mode, contentWidth = 600, mobileBreakpoint = 480) {
|
|
1116
1167
|
const widths = getWidthPercentages(cells);
|
|
1117
1168
|
if (mode === "email") {
|
|
1118
1169
|
const minQuery = `@media only screen and (min-width: ${contentWidth + 20}px)`;
|
|
@@ -1149,21 +1200,14 @@ ${widths.map(({ value, className }) => ` .no-stack .u-col-${className} { width:
|
|
|
1149
1200
|
const columnCSS = widths.map(
|
|
1150
1201
|
({ value, className }) => `.u-row .u-col.u-col-${className} { flex: 0 0 ${value}%; max-width: ${value}%; }`
|
|
1151
1202
|
).join("\n");
|
|
1152
|
-
const responsiveCSS = `
|
|
1153
|
-
@media
|
|
1154
|
-
.
|
|
1155
|
-
.u-row
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
min-width: 320px !important;
|
|
1203
|
+
const responsiveCSS = mode === "document" ? "" : `
|
|
1204
|
+
@media (max-width: ${mobileBreakpoint}px) {
|
|
1205
|
+
.u_row .container { max-width: 100% !important; }
|
|
1206
|
+
.u-row:not(.no-stack) { flex-wrap: wrap; }
|
|
1207
|
+
.u-row:not(.no-stack) .u-col {
|
|
1208
|
+
flex: 0 0 100% !important;
|
|
1159
1209
|
max-width: 100% !important;
|
|
1160
1210
|
}
|
|
1161
|
-
.u-row .u-col > div { margin: 0 auto; }
|
|
1162
|
-
.no-stack .u-col {
|
|
1163
|
-
min-width: 0 !important;
|
|
1164
|
-
display: table-cell !important;
|
|
1165
|
-
}
|
|
1166
|
-
${widths.map(({ value, className }) => ` .no-stack .u-col-${className} { width: ${value}% !important; }`).join("\n")}
|
|
1167
1211
|
}`;
|
|
1168
1212
|
return baseCSS + "\n" + columnCSS + "\n" + responsiveCSS;
|
|
1169
1213
|
}
|
|
@@ -1230,10 +1274,17 @@ var Row = (props) => {
|
|
|
1230
1274
|
...semanticProps
|
|
1231
1275
|
} = props;
|
|
1232
1276
|
const mode = modeProp ?? _config?.mode ?? "web";
|
|
1233
|
-
let cells
|
|
1277
|
+
let cells;
|
|
1234
1278
|
if (layout) {
|
|
1235
1279
|
validateColumnLayout(layout, React__default.default.Children.count(children));
|
|
1236
1280
|
cells = layout.cells;
|
|
1281
|
+
} else if (propsCells) {
|
|
1282
|
+
cells = propsCells;
|
|
1283
|
+
} else {
|
|
1284
|
+
const columnCount = React__default.default.Children.toArray(children).filter(
|
|
1285
|
+
(c) => React__default.default.isValidElement(c) && /^Column$/.test(c.type?.displayName || c.type?.name || "")
|
|
1286
|
+
).length;
|
|
1287
|
+
cells = Array(Math.max(1, columnCount)).fill(1);
|
|
1237
1288
|
}
|
|
1238
1289
|
const safeBodyValues = { ...DEFAULT_BODY_VALUES, ...bodyValues };
|
|
1239
1290
|
const values = mapSemanticProps(
|
|
@@ -1657,8 +1708,81 @@ function extractHeadFromTree(element, options) {
|
|
|
1657
1708
|
};
|
|
1658
1709
|
}
|
|
1659
1710
|
|
|
1711
|
+
// src/utils/document-layouts.ts
|
|
1712
|
+
function emailLayout(args) {
|
|
1713
|
+
const { dir, titleTag, tagLines, styleTag, scriptTag, fontLinks, body } = args;
|
|
1714
|
+
const head = [
|
|
1715
|
+
titleTag,
|
|
1716
|
+
tagLines,
|
|
1717
|
+
styleTag,
|
|
1718
|
+
scriptTag,
|
|
1719
|
+
fontLinks && `<!--[if !mso]><!-->${fontLinks}<!--<![endif]-->`
|
|
1720
|
+
].filter(Boolean).join("\n ");
|
|
1721
|
+
return `<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
1722
|
+
<html${dir} xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
1723
|
+
<head>
|
|
1724
|
+
<!--[if gte mso 9]>
|
|
1725
|
+
<xml>
|
|
1726
|
+
<o:OfficeDocumentSettings>
|
|
1727
|
+
<o:AllowPNG/>
|
|
1728
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
1729
|
+
</o:OfficeDocumentSettings>
|
|
1730
|
+
</xml>
|
|
1731
|
+
<![endif]-->
|
|
1732
|
+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
1733
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1734
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
1735
|
+
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
|
|
1736
|
+
${head}
|
|
1737
|
+
</head>
|
|
1738
|
+
${body}
|
|
1739
|
+
</html>
|
|
1740
|
+
`;
|
|
1741
|
+
}
|
|
1742
|
+
function documentLayout(args) {
|
|
1743
|
+
const { dir, titleTag, tagLines, styleTag, scriptTag, fontLinks, body } = args;
|
|
1744
|
+
const head = [titleTag, tagLines, styleTag, scriptTag, fontLinks].filter(Boolean).join("\n ");
|
|
1745
|
+
return `<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
1746
|
+
<html${dir} xmlns="http://www.w3.org/1999/xhtml">
|
|
1747
|
+
<head>
|
|
1748
|
+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
1749
|
+
${head}
|
|
1750
|
+
</head>
|
|
1751
|
+
${body}
|
|
1752
|
+
</html>
|
|
1753
|
+
`;
|
|
1754
|
+
}
|
|
1755
|
+
function webLayout(args) {
|
|
1756
|
+
const { dir, titleTag, tagLines, styleTag, scriptTag, fontLinks, body } = args;
|
|
1757
|
+
const head = [titleTag, fontLinks, tagLines, styleTag, scriptTag].filter(Boolean).join("\n ");
|
|
1758
|
+
return `<!doctype html>
|
|
1759
|
+
<html${dir}>
|
|
1760
|
+
<head>
|
|
1761
|
+
<meta charset="utf-8">
|
|
1762
|
+
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
1763
|
+
<meta name="description" content="">
|
|
1764
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1765
|
+
${head}
|
|
1766
|
+
</head>
|
|
1767
|
+
<body>
|
|
1768
|
+
${body}
|
|
1769
|
+
</body>
|
|
1770
|
+
</html>
|
|
1771
|
+
`;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1660
1774
|
// src/utils/render-to-html.tsx
|
|
1661
|
-
|
|
1775
|
+
var MODE_BY_WRAPPER = {
|
|
1776
|
+
Email: "email",
|
|
1777
|
+
Page: "web",
|
|
1778
|
+
Document: "document"
|
|
1779
|
+
};
|
|
1780
|
+
function resolveDisplayMode(element, mergedConfig) {
|
|
1781
|
+
const displayName = element.type?.displayName;
|
|
1782
|
+
const wrapperMode = displayName ? MODE_BY_WRAPPER[displayName] : void 0;
|
|
1783
|
+
return wrapperMode ?? element.props.mode ?? mergedConfig.mode ?? "web";
|
|
1784
|
+
}
|
|
1785
|
+
function renderBody(element, config) {
|
|
1662
1786
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
1663
1787
|
try {
|
|
1664
1788
|
const enriched = React__default.default.cloneElement(element, { config: mergedConfig });
|
|
@@ -1671,14 +1795,52 @@ Tip: Ensure your tree uses Body > Row > Column > Item structure.`
|
|
|
1671
1795
|
);
|
|
1672
1796
|
}
|
|
1673
1797
|
}
|
|
1798
|
+
function stripOuterDiv(html) {
|
|
1799
|
+
const match = html.match(/^\s*<div[^>]*>([\s\S]*)<\/div>\s*$/);
|
|
1800
|
+
return match ? match[1] : html;
|
|
1801
|
+
}
|
|
1802
|
+
function escapeForHtml(text) {
|
|
1803
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1804
|
+
}
|
|
1805
|
+
function renderToHtml(element, options) {
|
|
1806
|
+
const { title, fonts = [], ...config } = options ?? {};
|
|
1807
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
1808
|
+
const displayMode = resolveDisplayMode(element, mergedConfig);
|
|
1809
|
+
const wrapperName = element.type?.displayName;
|
|
1810
|
+
const isUnlayerWrapper = wrapperName === "Body" || (wrapperName ? wrapperName in MODE_BY_WRAPPER : false);
|
|
1811
|
+
const rawBody = renderBody(element, config);
|
|
1812
|
+
const body = isUnlayerWrapper ? stripOuterDiv(rawBody) : rawBody;
|
|
1813
|
+
const { css, js, tags } = extractHeadFromTree(element, {
|
|
1814
|
+
displayMode,
|
|
1815
|
+
headConfig: mergedConfig.headConfig
|
|
1816
|
+
});
|
|
1817
|
+
const layoutArgs = {
|
|
1818
|
+
dir: mergedConfig.textDirection ? ` dir="${escapeForHtml(String(mergedConfig.textDirection))}"` : "",
|
|
1819
|
+
titleTag: title ? `<title>${escapeForHtml(title.trim())}</title>` : "",
|
|
1820
|
+
styleTag: css ? `<style type="text/css">
|
|
1821
|
+
${css}
|
|
1822
|
+
</style>` : "",
|
|
1823
|
+
scriptTag: js ? `<script type="application/javascript">
|
|
1824
|
+
${js}
|
|
1825
|
+
</script>` : "",
|
|
1826
|
+
tagLines: tags.join("\n"),
|
|
1827
|
+
fontLinks: fonts.filter((font) => font?.url).map(
|
|
1828
|
+
(font) => `<link href="${escapeForHtml(font.url)}" rel="stylesheet" type="text/css">`
|
|
1829
|
+
).join(""),
|
|
1830
|
+
body
|
|
1831
|
+
};
|
|
1832
|
+
if (displayMode === "email") return emailLayout(layoutArgs);
|
|
1833
|
+
if (displayMode === "document") return documentLayout(layoutArgs);
|
|
1834
|
+
return webLayout(layoutArgs);
|
|
1835
|
+
}
|
|
1674
1836
|
function renderToPlainText(element, config) {
|
|
1675
|
-
const html =
|
|
1837
|
+
const html = renderBody(element, config);
|
|
1676
1838
|
return htmlToPlainText(html);
|
|
1677
1839
|
}
|
|
1678
1840
|
function renderToHtmlParts(element, config) {
|
|
1679
|
-
const body =
|
|
1841
|
+
const body = renderBody(element, config);
|
|
1680
1842
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
1681
|
-
const displayMode = element
|
|
1843
|
+
const displayMode = resolveDisplayMode(element, mergedConfig);
|
|
1682
1844
|
const { css, js, tags } = extractHeadFromTree(element, {
|
|
1683
1845
|
displayMode,
|
|
1684
1846
|
headConfig: mergedConfig.headConfig
|