@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 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 clean HTML string — no React hydration markers, perfect for email sending and PDF generation:
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
- if (!childrenHtml) return "<p><br></p>";
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 || "h1";
222
- const dir = node.direction ? ` 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 = 620) {
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 only screen and (max-width: ${mobileBreakpoint}px) {
1154
- .u-row { width: 100% !important; }
1155
- .u-row .u-col {
1156
- display: block !important;
1157
- width: 100% !important;
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 = propsCells || [1];
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
- function renderToHtml(element, config) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 = renderToHtml(element, config);
1837
+ const html = renderBody(element, config);
1676
1838
  return htmlToPlainText(html);
1677
1839
  }
1678
1840
  function renderToHtmlParts(element, config) {
1679
- const body = renderToHtml(element, config);
1841
+ const body = renderBody(element, config);
1680
1842
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
1681
- const displayMode = element.props.mode ?? mergedConfig.mode ?? "web";
1843
+ const displayMode = resolveDisplayMode(element, mergedConfig);
1682
1844
  const { css, js, tags } = extractHeadFromTree(element, {
1683
1845
  displayMode,
1684
1846
  headConfig: mergedConfig.headConfig