@unlayer/react-elements 0.1.16 → 0.1.18

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,44 @@ const html = renderToHtml(
311
321
  </Button>
312
322
  </Column>
313
323
  </Row>
324
+ </Email>,
325
+ { title: "Welcome" }
326
+ );
327
+ ```
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
+
336
+ ## Custom Tools
337
+
338
+ **A custom tool is the custom component you create.** We keep the editor's term — Custom Tools — because it's the same API across the Builder and Elements, and that shared vocabulary is where the power is: the same definition you pass to the editor's [`unlayer.registerTool`](https://docs.unlayer.com/builder/tools/custom) renders from code too. Elements uses the `renderer.exporters` and `renderer.head` halves of the definition; the editor-only parts (`label`, `icon`, `options`, `Viewer`, `validator`) are accepted and ignored:
339
+
340
+ ```tsx
341
+ import { registerTool, Email, Row, Column, renderToHtml, renderToJson } from '@unlayer/react-elements';
342
+ import { countdownTool } from './countdown-tool'; // the same object you register in the editor
343
+
344
+ const Countdown = registerTool(countdownTool); // same term, same config as the editor
345
+
346
+ const tree = (
347
+ <Email>
348
+ <Row><Column>
349
+ <Countdown endTime="2026-08-01T00:00:00Z" digitColor="#e11d48" />
350
+ </Column></Row>
314
351
  </Email>
315
352
  );
353
+
354
+ renderToHtml(tree); // renders via YOUR exporters — identical output to editor exports
355
+ renderToJson(tree); // emits { type: "custom", slug: "countdown", values } — opens in the Builder as the real tool
316
356
  ```
317
357
 
358
+ - Props are the tool's `values` (flat), merged over the definition's defaults; `values={{...}}` works as an escape hatch.
359
+ - Modes fall back like the editor: `document` uses the `web` exporter; an email-only tool renders via its `email` exporter everywhere.
360
+ - **Sanitization:** when your config provides `toSafeHtml`, the tool's HTML output is passed through it (matching editor exports). The default config has no sanitizer — configure one if tool values can contain untrusted content.
361
+
318
362
  ## UnlayerProvider
319
363
 
320
364
  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": {
@@ -579,16 +620,6 @@ function nextHtmlId(config, prefix) {
579
620
  ids[prefix] = (ids[prefix] || 0) + 1;
580
621
  return `${prefix}_${ids[prefix]}`;
581
622
  }
582
- function ensureMeta(values, type, index = 0) {
583
- return {
584
- ...values,
585
- _meta: {
586
- htmlID: `u_content_${type.toLowerCase()}_${index + 1}`,
587
- htmlClassNames: `u_content_${type.toLowerCase()}`,
588
- ...values._meta || {}
589
- }
590
- };
591
- }
592
623
  function renderComponent(config) {
593
624
  const { type, values, className, style, args = [], innerHTML, _config, exporter, metaContext } = config;
594
625
  try {
@@ -641,6 +672,7 @@ function createItemComponent(config) {
641
672
  style,
642
673
  // Internal props
643
674
  index = 0,
675
+ _metaHtmlId,
644
676
  colIndex = 0,
645
677
  cells = [],
646
678
  bodyValues = {},
@@ -658,11 +690,15 @@ function createItemComponent(config) {
658
690
  ...restProps
659
691
  });
660
692
  const finalValues = mergeValues(config.defaultValues, mappedValues);
661
- const valuesWithMeta = ensureMeta(
662
- finalValues,
663
- config.name.toLowerCase(),
664
- index
665
- );
693
+ const metaName = config.metaName ?? config.name.toLowerCase();
694
+ const valuesWithMeta = {
695
+ ...finalValues,
696
+ _meta: {
697
+ htmlID: _metaHtmlId ?? `u_content_${metaName}_${index + 1}`,
698
+ htmlClassNames: `u_content_${metaName}`,
699
+ ...finalValues._meta || {}
700
+ }
701
+ };
666
702
  const safeBodyValues = {
667
703
  contentWidth: "500px",
668
704
  ...bodyValues
@@ -711,7 +747,11 @@ function createItemComponent(config) {
711
747
  ItemComponent[UNLAYER_CONFIG_KEY] = {
712
748
  name: config.name,
713
749
  defaultValues: config.defaultValues,
714
- propMapper: config.propMapper
750
+ propMapper: config.propMapper,
751
+ contentType: config.contentType,
752
+ slug: config.slug,
753
+ metaName: config.metaName,
754
+ head: config.head
715
755
  };
716
756
  return ItemComponent;
717
757
  }
@@ -1122,7 +1162,7 @@ function getWidthPercentages(cells) {
1122
1162
  return { value, className };
1123
1163
  });
1124
1164
  }
1125
- function generateGridCSS(cells, mode, contentWidth = 600, mobileBreakpoint = 620) {
1165
+ function generateGridCSS(cells, mode, contentWidth = 600, mobileBreakpoint = 480) {
1126
1166
  const widths = getWidthPercentages(cells);
1127
1167
  if (mode === "email") {
1128
1168
  const minQuery = `@media only screen and (min-width: ${contentWidth + 20}px)`;
@@ -1159,21 +1199,14 @@ ${widths.map(({ value, className }) => ` .no-stack .u-col-${className} { width:
1159
1199
  const columnCSS = widths.map(
1160
1200
  ({ value, className }) => `.u-row .u-col.u-col-${className} { flex: 0 0 ${value}%; max-width: ${value}%; }`
1161
1201
  ).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;
1202
+ const responsiveCSS = mode === "document" ? "" : `
1203
+ @media (max-width: ${mobileBreakpoint}px) {
1204
+ .u_row .container { max-width: 100% !important; }
1205
+ .u-row:not(.no-stack) { flex-wrap: wrap; }
1206
+ .u-row:not(.no-stack) .u-col {
1207
+ flex: 0 0 100% !important;
1169
1208
  max-width: 100% !important;
1170
1209
  }
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
1210
  }`;
1178
1211
  return baseCSS + "\n" + columnCSS + "\n" + responsiveCSS;
1179
1212
  }
@@ -1340,9 +1373,12 @@ var Column = (props) => {
1340
1373
  if (typeof child.type === "function") {
1341
1374
  const ComponentType = child.type;
1342
1375
  const renderFn = ComponentType[UNLAYER_RENDER_KEY] || ComponentType;
1376
+ const componentName = ComponentType?.[UNLAYER_CONFIG_KEY]?.metaName ?? (ComponentType?.displayName || ComponentType?.name || "component").toLowerCase();
1377
+ const contentHtmlId = nextHtmlId(_config, `u_content_${componentName}`);
1343
1378
  const rendered = renderFn({
1344
1379
  ...child.props,
1345
1380
  _config,
1381
+ _metaHtmlId: contentHtmlId,
1346
1382
  colIndex: index,
1347
1383
  cells,
1348
1384
  bodyValues,
@@ -1351,15 +1387,13 @@ var Column = (props) => {
1351
1387
  });
1352
1388
  if (rendered && typeof rendered === "object" && rendered.props && rendered.props.dangerouslySetInnerHTML) {
1353
1389
  const componentHTML = rendered.props.dangerouslySetInnerHTML.__html;
1354
- const componentType = child.type;
1355
- const componentName = (componentType?.displayName || componentType?.name || "component").toLowerCase();
1356
1390
  const childProps = child.props;
1357
1391
  const rawContainerPadding = childProps.containerPadding ?? childProps.values?.containerPadding ?? DEFAULT_CONTAINER_PADDING2;
1358
1392
  const containerPadding = typeof rawContainerPadding === "number" ? `${rawContainerPadding}px` : rawContainerPadding;
1359
1393
  const contentValues = {
1360
1394
  containerPadding,
1361
1395
  _meta: {
1362
- htmlID: nextHtmlId(_config, `u_content_${componentName}`),
1396
+ htmlID: contentHtmlId,
1363
1397
  htmlClassNames: `u_content_${componentName}`
1364
1398
  }
1365
1399
  };
@@ -1530,7 +1564,7 @@ UnlayerProvider.displayName = "UnlayerProvider";
1530
1564
  function useUnlayerConfig() {
1531
1565
  return React.useContext(getUnlayerContext());
1532
1566
  }
1533
- function ensureMeta2(values, type, index = 0) {
1567
+ function ensureMeta(values, type, index = 0) {
1534
1568
  return {
1535
1569
  ...values,
1536
1570
  _meta: {
@@ -1611,17 +1645,17 @@ function walkItem(element, bodyValues, displayMode, headConfig, counters, styles
1611
1645
  const { children, ...restProps } = element.props;
1612
1646
  const mappedValues = propMapper({ children, ...restProps });
1613
1647
  const finalValues = mergeValues(defaultValues, mappedValues);
1614
- const contentType = name.toLowerCase();
1648
+ const contentType = config.metaName ?? name.toLowerCase();
1615
1649
  const count = nextCounter(counters, `u_content_${contentType}`);
1616
- const valuesWithMeta = ensureMeta2(finalValues, contentType, count - 1);
1617
- const head = exporters.heads[name];
1650
+ const valuesWithMeta = ensureMeta(finalValues, contentType, count - 1);
1651
+ const head = config.head ?? exporters.heads[name];
1618
1652
  callHead(head, valuesWithMeta, bodyValues, displayMode, headConfig, styles, scripts, tags);
1619
1653
  }
1620
1654
  function walkColumn(element, bodyValues, rowValues, displayMode, headConfig, counters, styles, scripts, tags) {
1621
1655
  const semanticProps = extractSemanticProps(element.props);
1622
1656
  const mapped = mapSemanticProps(semanticProps, COLUMN_DEFAULTS, "Column");
1623
1657
  const count = nextCounter(counters, "u_column");
1624
- const columnValues = ensureMeta2(mergeValues(COLUMN_DEFAULTS, mapped), "column", count - 1);
1658
+ const columnValues = ensureMeta(mergeValues(COLUMN_DEFAULTS, mapped), "column", count - 1);
1625
1659
  const columnHead = exporters.heads["Column"];
1626
1660
  callHead(columnHead, columnValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1627
1661
  const children = collectChildren(element.props.children);
@@ -1633,7 +1667,7 @@ function walkRow(element, bodyValues, displayMode, headConfig, counters, styles,
1633
1667
  const semanticProps = extractSemanticProps(element.props, ["layout"]);
1634
1668
  const mapped = mapSemanticProps(semanticProps, ROW_DEFAULTS, "Row");
1635
1669
  const count = nextCounter(counters, "u_row");
1636
- const rowValues = ensureMeta2(mergeValues(ROW_DEFAULTS, mapped), "row", count - 1);
1670
+ const rowValues = ensureMeta(mergeValues(ROW_DEFAULTS, mapped), "row", count - 1);
1637
1671
  const rowHead = exporters.heads["Row"];
1638
1672
  callHead(rowHead, rowValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1639
1673
  const children = collectChildren(element.props.children);
@@ -1656,7 +1690,7 @@ function extractHeadFromTree(element, options) {
1656
1690
  const counters = {};
1657
1691
  const semanticProps = extractSemanticProps(element.props);
1658
1692
  const mapped = mapSemanticProps(semanticProps, BODY_DEFAULTS, "Body");
1659
- const bodyValues = ensureMeta2(mergeValues(BODY_DEFAULTS, mapped), "body", 0);
1693
+ const bodyValues = ensureMeta(mergeValues(BODY_DEFAULTS, mapped), "body", 0);
1660
1694
  const bodyHead = exporters.heads["Body"];
1661
1695
  callHead(bodyHead, bodyValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1662
1696
  const children = collectChildren(element.props.children);
@@ -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
@@ -1818,10 +1963,11 @@ function processItem(element, counters, layout = {}) {
1818
1963
  `[Unlayer] renderToJson: <${name2}> is not a recognized Unlayer item component. Only components created with createItemComponent are supported.`
1819
1964
  );
1820
1965
  }
1821
- const { name, defaultValues, propMapper } = config;
1822
- const contentType = toContentType(name);
1823
- const count = nextCounter2(counters, `u_content_${contentType}`);
1824
- const id = makeId(`u_content_${contentType}`, count);
1966
+ const { name, defaultValues, propMapper, slug } = config;
1967
+ const contentType = config.contentType ?? toContentType(name);
1968
+ const metaBase = `u_content_${config.metaName ?? contentType}`;
1969
+ const count = nextCounter2(counters, metaBase);
1970
+ const id = makeId(metaBase, count);
1825
1971
  const { children, ...restProps } = element.props;
1826
1972
  const mappedValues = propMapper({ children, ...restProps });
1827
1973
  const finalValues = mergeValues(defaultValues, mappedValues);
@@ -1834,7 +1980,7 @@ function processItem(element, counters, layout = {}) {
1834
1980
  ...finalValues,
1835
1981
  _meta: {
1836
1982
  htmlID: id,
1837
- htmlClassNames: `u_content_${contentType}`,
1983
+ htmlClassNames: metaBase,
1838
1984
  ...finalValues._meta || {}
1839
1985
  },
1840
1986
  selectable: true,
@@ -1851,7 +1997,7 @@ function processItem(element, counters, layout = {}) {
1851
1997
  });
1852
1998
  values.src = pinImageSrc(itemSrc, availableWidth);
1853
1999
  }
1854
- return { type: contentType, values };
2000
+ return slug ? { type: contentType, slug, values } : { type: contentType, values };
1855
2001
  }
1856
2002
  function processColumn(element, counters, layout = {}) {
1857
2003
  const count = nextCounter2(counters, "u_column");
@@ -1995,6 +2141,89 @@ function renderToJson(element) {
1995
2141
  };
1996
2142
  }
1997
2143
 
2144
+ // src/utils/register-tool.tsx
2145
+ function initialValuesFromOptions(options) {
2146
+ const result = {};
2147
+ for (const group of Object.values(options ?? {})) {
2148
+ for (const [property, option] of Object.entries(group?.options ?? {})) {
2149
+ if (!option || option.enabled === false) continue;
2150
+ if (typeof option.defaultValue !== "undefined") {
2151
+ result[property] = option.defaultValue;
2152
+ }
2153
+ }
2154
+ }
2155
+ return result;
2156
+ }
2157
+ function withSanitizer(exporter) {
2158
+ return (values, ...args) => {
2159
+ const html = String(exporter(values, ...args) ?? "");
2160
+ const meta = args[args.length - 1];
2161
+ const toSafeHtml = meta?.exporterConfig?.toSafeHtml;
2162
+ return typeof toSafeHtml === "function" ? toSafeHtml(html) : html;
2163
+ };
2164
+ }
2165
+ function withLinkNormalization(exporter, linkKeys) {
2166
+ if (linkKeys.length === 0) return exporter;
2167
+ return (values, ...args) => {
2168
+ const out = { ...values };
2169
+ for (const key of linkKeys) {
2170
+ const normalized = normalizeLinkValue(out[key]);
2171
+ if (normalized !== void 0) out[key] = normalized;
2172
+ }
2173
+ return exporter(out, ...args);
2174
+ };
2175
+ }
2176
+ function linkWidgetKeys(options) {
2177
+ const keys = [];
2178
+ for (const group of Object.values(options ?? {})) {
2179
+ for (const [key, option] of Object.entries(group?.options ?? {})) {
2180
+ if (option && option.widget === "link") keys.push(key);
2181
+ }
2182
+ }
2183
+ return keys;
2184
+ }
2185
+ function registerTool(tool) {
2186
+ if (!tool?.name || typeof tool.name !== "string") {
2187
+ throw new Error("[Unlayer] registerTool: `name` is required");
2188
+ }
2189
+ const exporters = tool.renderer?.exporters;
2190
+ if (!exporters || !exporters.web && !exporters.email) {
2191
+ throw new Error(
2192
+ `[Unlayer] registerTool("${tool.name}"): \`renderer.exporters\` needs at least a \`web\` or \`email\` exporter`
2193
+ );
2194
+ }
2195
+ const linkKeys = linkWidgetKeys(tool.options);
2196
+ const sanitized = {};
2197
+ for (const mode of ["web", "email", "document"]) {
2198
+ const exporter = exporters[mode];
2199
+ if (exporter) {
2200
+ sanitized[mode] = withSanitizer(withLinkNormalization(exporter, linkKeys));
2201
+ }
2202
+ }
2203
+ if (!sanitized.web && sanitized.email) sanitized.web = sanitized.email;
2204
+ return createItemComponent({
2205
+ name: tool.name,
2206
+ displayName: tool.displayName ?? tool.name,
2207
+ // Option-group widget defaults seed the values; tool-level `values`
2208
+ // win — the editor's precedence.
2209
+ defaultValues: {
2210
+ ...initialValuesFromOptions(tool.options),
2211
+ ...tool.values ?? {}
2212
+ },
2213
+ // Flat props are the tool's values; `values` is the escape hatch.
2214
+ propMapper: ({ children: _children, values, ...rest }) => ({
2215
+ ...values ?? {},
2216
+ ...rest
2217
+ }),
2218
+ exporters: sanitized,
2219
+ contentType: "custom",
2220
+ slug: tool.name,
2221
+ metaName: `custom_${tool.name}`,
2222
+ head: tool.renderer.head
2223
+ });
2224
+ }
2225
+ var registerElementsTool = registerTool;
2226
+
1998
2227
  exports.Body = Body_default;
1999
2228
  exports.Button = Button_default;
2000
2229
  exports.Column = Column_default;
@@ -2015,6 +2244,8 @@ exports.Table = Table_default;
2015
2244
  exports.UnlayerProvider = UnlayerProvider;
2016
2245
  exports.Video = Video_default;
2017
2246
  exports.htmlToTextJson = htmlToTextJson;
2247
+ exports.registerElementsTool = registerElementsTool;
2248
+ exports.registerTool = registerTool;
2018
2249
  exports.renderRowToJson = renderRowToJson;
2019
2250
  exports.renderToHtml = renderToHtml;
2020
2251
  exports.renderToHtmlParts = renderToHtmlParts;