@unlayer/react-elements 0.1.16 → 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": {
@@ -1122,7 +1163,7 @@ function getWidthPercentages(cells) {
1122
1163
  return { value, className };
1123
1164
  });
1124
1165
  }
1125
- function generateGridCSS(cells, mode, contentWidth = 600, mobileBreakpoint = 620) {
1166
+ function generateGridCSS(cells, mode, contentWidth = 600, mobileBreakpoint = 480) {
1126
1167
  const widths = getWidthPercentages(cells);
1127
1168
  if (mode === "email") {
1128
1169
  const minQuery = `@media only screen and (min-width: ${contentWidth + 20}px)`;
@@ -1159,21 +1200,14 @@ ${widths.map(({ value, className }) => ` .no-stack .u-col-${className} { width:
1159
1200
  const columnCSS = widths.map(
1160
1201
  ({ value, className }) => `.u-row .u-col.u-col-${className} { flex: 0 0 ${value}%; max-width: ${value}%; }`
1161
1202
  ).join("\n");
1162
- const responsiveCSS = `
1163
- @media only screen and (max-width: ${mobileBreakpoint}px) {
1164
- .u-row { width: 100% !important; }
1165
- .u-row .u-col {
1166
- display: block !important;
1167
- width: 100% !important;
1168
- 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;
1169
1209
  max-width: 100% !important;
1170
1210
  }
1171
- .u-row .u-col > div { margin: 0 auto; }
1172
- .no-stack .u-col {
1173
- min-width: 0 !important;
1174
- display: table-cell !important;
1175
- }
1176
- ${widths.map(({ value, className }) => ` .no-stack .u-col-${className} { width: ${value}% !important; }`).join("\n")}
1177
1211
  }`;
1178
1212
  return baseCSS + "\n" + columnCSS + "\n" + responsiveCSS;
1179
1213
  }
@@ -1674,8 +1708,81 @@ function extractHeadFromTree(element, options) {
1674
1708
  };
1675
1709
  }
1676
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
+
1677
1774
  // src/utils/render-to-html.tsx
1678
- 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) {
1679
1786
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
1680
1787
  try {
1681
1788
  const enriched = React__default.default.cloneElement(element, { config: mergedConfig });
@@ -1688,14 +1795,52 @@ Tip: Ensure your tree uses Body > Row > Column > Item structure.`
1688
1795
  );
1689
1796
  }
1690
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
+ }
1691
1836
  function renderToPlainText(element, config) {
1692
- const html = renderToHtml(element, config);
1837
+ const html = renderBody(element, config);
1693
1838
  return htmlToPlainText(html);
1694
1839
  }
1695
1840
  function renderToHtmlParts(element, config) {
1696
- const body = renderToHtml(element, config);
1841
+ const body = renderBody(element, config);
1697
1842
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
1698
- const displayMode = element.props.mode ?? mergedConfig.mode ?? "web";
1843
+ const displayMode = resolveDisplayMode(element, mergedConfig);
1699
1844
  const { css, js, tags } = extractHeadFromTree(element, {
1700
1845
  displayMode,
1701
1846
  headConfig: mergedConfig.headConfig