@unlayer/react-elements 0.1.17 → 0.1.19

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
@@ -331,7 +331,38 @@ The document shell matches the mode: `<Email>` gets the XHTML transitional docty
331
331
  - `title` — document `<title>`
332
332
  - `fonts` — web-font stylesheet URLs to load via `<link>` tags, e.g. `[{ url: "https://fonts.googleapis.com/css2?family=Inter" }]`
333
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.
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 }` plus the granular pieces (`css`, `js`, `tags`) for pipelines that need them separately, e.g. CSS inlining. This mirrors the editor's [chunk parameters](https://docs.unlayer.com/builder/latest/export-html#chunk-parameters) with two deliberate differences: `tags` is extra (components can contribute head tags, and reassembling from `css` + `js` alone would drop them), and there is no `fonts` chunk (Elements has no font registry — pass font stylesheet URLs via `renderToHtml`'s `fonts` option instead).
335
+
336
+ ```tsx
337
+ const { head, body, css, js, tags } = renderToHtmlParts(<Email>...</Email>);
338
+ // head = css/js/tags pre-assembled; css = raw stylesheet (no <style> wrapper)
339
+ ```
340
+
341
+ ## Custom Tools
342
+
343
+ **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:
344
+
345
+ ```tsx
346
+ import { registerTool, Email, Row, Column, renderToHtml, renderToJson } from '@unlayer/react-elements';
347
+ import { countdownTool } from './countdown-tool'; // the same object you register in the editor
348
+
349
+ const Countdown = registerTool(countdownTool); // same term, same config as the editor
350
+
351
+ const tree = (
352
+ <Email>
353
+ <Row><Column>
354
+ <Countdown endTime="2026-08-01T00:00:00Z" digitColor="#e11d48" />
355
+ </Column></Row>
356
+ </Email>
357
+ );
358
+
359
+ renderToHtml(tree); // renders via YOUR exporters — identical output to editor exports
360
+ renderToJson(tree); // emits { type: "custom", slug: "countdown", values } — opens in the Builder as the real tool
361
+ ```
362
+
363
+ - Props are the tool's `values` (flat), merged over the definition's defaults; `values={{...}}` works as an escape hatch.
364
+ - Modes fall back like the editor: `document` uses the `web` exporter; an email-only tool renders via its `email` exporter everywhere.
365
+ - **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.
335
366
 
336
367
  ## UnlayerProvider
337
368
 
package/dist/index.cjs CHANGED
@@ -620,16 +620,6 @@ function nextHtmlId(config, prefix) {
620
620
  ids[prefix] = (ids[prefix] || 0) + 1;
621
621
  return `${prefix}_${ids[prefix]}`;
622
622
  }
623
- function ensureMeta(values, type, index = 0) {
624
- return {
625
- ...values,
626
- _meta: {
627
- htmlID: `u_content_${type.toLowerCase()}_${index + 1}`,
628
- htmlClassNames: `u_content_${type.toLowerCase()}`,
629
- ...values._meta || {}
630
- }
631
- };
632
- }
633
623
  function renderComponent(config) {
634
624
  const { type, values, className, style, args = [], innerHTML, _config, exporter, metaContext } = config;
635
625
  try {
@@ -682,6 +672,7 @@ function createItemComponent(config) {
682
672
  style,
683
673
  // Internal props
684
674
  index = 0,
675
+ _metaHtmlId,
685
676
  colIndex = 0,
686
677
  cells = [],
687
678
  bodyValues = {},
@@ -699,11 +690,15 @@ function createItemComponent(config) {
699
690
  ...restProps
700
691
  });
701
692
  const finalValues = mergeValues(config.defaultValues, mappedValues);
702
- const valuesWithMeta = ensureMeta(
703
- finalValues,
704
- config.name.toLowerCase(),
705
- index
706
- );
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
+ };
707
702
  const safeBodyValues = {
708
703
  contentWidth: "500px",
709
704
  ...bodyValues
@@ -752,7 +747,11 @@ function createItemComponent(config) {
752
747
  ItemComponent[UNLAYER_CONFIG_KEY] = {
753
748
  name: config.name,
754
749
  defaultValues: config.defaultValues,
755
- propMapper: config.propMapper
750
+ propMapper: config.propMapper,
751
+ contentType: config.contentType,
752
+ slug: config.slug,
753
+ metaName: config.metaName,
754
+ head: config.head
756
755
  };
757
756
  return ItemComponent;
758
757
  }
@@ -1374,9 +1373,12 @@ var Column = (props) => {
1374
1373
  if (typeof child.type === "function") {
1375
1374
  const ComponentType = child.type;
1376
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}`);
1377
1378
  const rendered = renderFn({
1378
1379
  ...child.props,
1379
1380
  _config,
1381
+ _metaHtmlId: contentHtmlId,
1380
1382
  colIndex: index,
1381
1383
  cells,
1382
1384
  bodyValues,
@@ -1385,15 +1387,13 @@ var Column = (props) => {
1385
1387
  });
1386
1388
  if (rendered && typeof rendered === "object" && rendered.props && rendered.props.dangerouslySetInnerHTML) {
1387
1389
  const componentHTML = rendered.props.dangerouslySetInnerHTML.__html;
1388
- const componentType = child.type;
1389
- const componentName = (componentType?.displayName || componentType?.name || "component").toLowerCase();
1390
1390
  const childProps = child.props;
1391
1391
  const rawContainerPadding = childProps.containerPadding ?? childProps.values?.containerPadding ?? DEFAULT_CONTAINER_PADDING2;
1392
1392
  const containerPadding = typeof rawContainerPadding === "number" ? `${rawContainerPadding}px` : rawContainerPadding;
1393
1393
  const contentValues = {
1394
1394
  containerPadding,
1395
1395
  _meta: {
1396
- htmlID: nextHtmlId(_config, `u_content_${componentName}`),
1396
+ htmlID: contentHtmlId,
1397
1397
  htmlClassNames: `u_content_${componentName}`
1398
1398
  }
1399
1399
  };
@@ -1564,7 +1564,7 @@ UnlayerProvider.displayName = "UnlayerProvider";
1564
1564
  function useUnlayerConfig() {
1565
1565
  return React.useContext(getUnlayerContext());
1566
1566
  }
1567
- function ensureMeta2(values, type, index = 0) {
1567
+ function ensureMeta(values, type, index = 0) {
1568
1568
  return {
1569
1569
  ...values,
1570
1570
  _meta: {
@@ -1645,17 +1645,17 @@ function walkItem(element, bodyValues, displayMode, headConfig, counters, styles
1645
1645
  const { children, ...restProps } = element.props;
1646
1646
  const mappedValues = propMapper({ children, ...restProps });
1647
1647
  const finalValues = mergeValues(defaultValues, mappedValues);
1648
- const contentType = name.toLowerCase();
1648
+ const contentType = config.metaName ?? name.toLowerCase();
1649
1649
  const count = nextCounter(counters, `u_content_${contentType}`);
1650
- const valuesWithMeta = ensureMeta2(finalValues, contentType, count - 1);
1651
- const head = exporters.heads[name];
1650
+ const valuesWithMeta = ensureMeta(finalValues, contentType, count - 1);
1651
+ const head = config.head ?? exporters.heads[name];
1652
1652
  callHead(head, valuesWithMeta, bodyValues, displayMode, headConfig, styles, scripts, tags);
1653
1653
  }
1654
1654
  function walkColumn(element, bodyValues, rowValues, displayMode, headConfig, counters, styles, scripts, tags) {
1655
1655
  const semanticProps = extractSemanticProps(element.props);
1656
1656
  const mapped = mapSemanticProps(semanticProps, COLUMN_DEFAULTS, "Column");
1657
1657
  const count = nextCounter(counters, "u_column");
1658
- const columnValues = ensureMeta2(mergeValues(COLUMN_DEFAULTS, mapped), "column", count - 1);
1658
+ const columnValues = ensureMeta(mergeValues(COLUMN_DEFAULTS, mapped), "column", count - 1);
1659
1659
  const columnHead = exporters.heads["Column"];
1660
1660
  callHead(columnHead, columnValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1661
1661
  const children = collectChildren(element.props.children);
@@ -1667,7 +1667,7 @@ function walkRow(element, bodyValues, displayMode, headConfig, counters, styles,
1667
1667
  const semanticProps = extractSemanticProps(element.props, ["layout"]);
1668
1668
  const mapped = mapSemanticProps(semanticProps, ROW_DEFAULTS, "Row");
1669
1669
  const count = nextCounter(counters, "u_row");
1670
- const rowValues = ensureMeta2(mergeValues(ROW_DEFAULTS, mapped), "row", count - 1);
1670
+ const rowValues = ensureMeta(mergeValues(ROW_DEFAULTS, mapped), "row", count - 1);
1671
1671
  const rowHead = exporters.heads["Row"];
1672
1672
  callHead(rowHead, rowValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1673
1673
  const children = collectChildren(element.props.children);
@@ -1690,7 +1690,7 @@ function extractHeadFromTree(element, options) {
1690
1690
  const counters = {};
1691
1691
  const semanticProps = extractSemanticProps(element.props);
1692
1692
  const mapped = mapSemanticProps(semanticProps, BODY_DEFAULTS, "Body");
1693
- const bodyValues = ensureMeta2(mergeValues(BODY_DEFAULTS, mapped), "body", 0);
1693
+ const bodyValues = ensureMeta(mergeValues(BODY_DEFAULTS, mapped), "body", 0);
1694
1694
  const bodyHead = exporters.heads["Body"];
1695
1695
  callHead(bodyHead, bodyValues, bodyValues, displayMode, headConfig, styles, scripts, tags);
1696
1696
  const children = collectChildren(element.props.children);
@@ -1857,7 +1857,10 @@ function renderToHtmlParts(element, config) {
1857
1857
  }
1858
1858
  return {
1859
1859
  head: headParts.join("\n"),
1860
- body
1860
+ body,
1861
+ css,
1862
+ js,
1863
+ tags
1861
1864
  };
1862
1865
  }
1863
1866
  var schemaVersion = exporters.schemaVersion ?? 24;
@@ -1963,10 +1966,11 @@ function processItem(element, counters, layout = {}) {
1963
1966
  `[Unlayer] renderToJson: <${name2}> is not a recognized Unlayer item component. Only components created with createItemComponent are supported.`
1964
1967
  );
1965
1968
  }
1966
- const { name, defaultValues, propMapper } = config;
1967
- const contentType = toContentType(name);
1968
- const count = nextCounter2(counters, `u_content_${contentType}`);
1969
- const id = makeId(`u_content_${contentType}`, count);
1969
+ const { name, defaultValues, propMapper, slug } = config;
1970
+ const contentType = config.contentType ?? toContentType(name);
1971
+ const metaBase = `u_content_${config.metaName ?? contentType}`;
1972
+ const count = nextCounter2(counters, metaBase);
1973
+ const id = makeId(metaBase, count);
1970
1974
  const { children, ...restProps } = element.props;
1971
1975
  const mappedValues = propMapper({ children, ...restProps });
1972
1976
  const finalValues = mergeValues(defaultValues, mappedValues);
@@ -1979,7 +1983,7 @@ function processItem(element, counters, layout = {}) {
1979
1983
  ...finalValues,
1980
1984
  _meta: {
1981
1985
  htmlID: id,
1982
- htmlClassNames: `u_content_${contentType}`,
1986
+ htmlClassNames: metaBase,
1983
1987
  ...finalValues._meta || {}
1984
1988
  },
1985
1989
  selectable: true,
@@ -1996,7 +2000,7 @@ function processItem(element, counters, layout = {}) {
1996
2000
  });
1997
2001
  values.src = pinImageSrc(itemSrc, availableWidth);
1998
2002
  }
1999
- return { type: contentType, values };
2003
+ return slug ? { type: contentType, slug, values } : { type: contentType, values };
2000
2004
  }
2001
2005
  function processColumn(element, counters, layout = {}) {
2002
2006
  const count = nextCounter2(counters, "u_column");
@@ -2140,6 +2144,89 @@ function renderToJson(element) {
2140
2144
  };
2141
2145
  }
2142
2146
 
2147
+ // src/utils/register-tool.tsx
2148
+ function initialValuesFromOptions(options) {
2149
+ const result = {};
2150
+ for (const group of Object.values(options ?? {})) {
2151
+ for (const [property, option] of Object.entries(group?.options ?? {})) {
2152
+ if (!option || option.enabled === false) continue;
2153
+ if (typeof option.defaultValue !== "undefined") {
2154
+ result[property] = option.defaultValue;
2155
+ }
2156
+ }
2157
+ }
2158
+ return result;
2159
+ }
2160
+ function withSanitizer(exporter) {
2161
+ return (values, ...args) => {
2162
+ const html = String(exporter(values, ...args) ?? "");
2163
+ const meta = args[args.length - 1];
2164
+ const toSafeHtml = meta?.exporterConfig?.toSafeHtml;
2165
+ return typeof toSafeHtml === "function" ? toSafeHtml(html) : html;
2166
+ };
2167
+ }
2168
+ function withLinkNormalization(exporter, linkKeys) {
2169
+ if (linkKeys.length === 0) return exporter;
2170
+ return (values, ...args) => {
2171
+ const out = { ...values };
2172
+ for (const key of linkKeys) {
2173
+ const normalized = normalizeLinkValue(out[key]);
2174
+ if (normalized !== void 0) out[key] = normalized;
2175
+ }
2176
+ return exporter(out, ...args);
2177
+ };
2178
+ }
2179
+ function linkWidgetKeys(options) {
2180
+ const keys = [];
2181
+ for (const group of Object.values(options ?? {})) {
2182
+ for (const [key, option] of Object.entries(group?.options ?? {})) {
2183
+ if (option && option.widget === "link") keys.push(key);
2184
+ }
2185
+ }
2186
+ return keys;
2187
+ }
2188
+ function registerTool(tool) {
2189
+ if (!tool?.name || typeof tool.name !== "string") {
2190
+ throw new Error("[Unlayer] registerTool: `name` is required");
2191
+ }
2192
+ const exporters = tool.renderer?.exporters;
2193
+ if (!exporters || !exporters.web && !exporters.email) {
2194
+ throw new Error(
2195
+ `[Unlayer] registerTool("${tool.name}"): \`renderer.exporters\` needs at least a \`web\` or \`email\` exporter`
2196
+ );
2197
+ }
2198
+ const linkKeys = linkWidgetKeys(tool.options);
2199
+ const sanitized = {};
2200
+ for (const mode of ["web", "email", "document"]) {
2201
+ const exporter = exporters[mode];
2202
+ if (exporter) {
2203
+ sanitized[mode] = withSanitizer(withLinkNormalization(exporter, linkKeys));
2204
+ }
2205
+ }
2206
+ if (!sanitized.web && sanitized.email) sanitized.web = sanitized.email;
2207
+ return createItemComponent({
2208
+ name: tool.name,
2209
+ displayName: tool.displayName ?? tool.name,
2210
+ // Option-group widget defaults seed the values; tool-level `values`
2211
+ // win — the editor's precedence.
2212
+ defaultValues: {
2213
+ ...initialValuesFromOptions(tool.options),
2214
+ ...tool.values ?? {}
2215
+ },
2216
+ // Flat props are the tool's values; `values` is the escape hatch.
2217
+ propMapper: ({ children: _children, values, ...rest }) => ({
2218
+ ...values ?? {},
2219
+ ...rest
2220
+ }),
2221
+ exporters: sanitized,
2222
+ contentType: "custom",
2223
+ slug: tool.name,
2224
+ metaName: `custom_${tool.name}`,
2225
+ head: tool.renderer.head
2226
+ });
2227
+ }
2228
+ var registerElementsTool = registerTool;
2229
+
2143
2230
  exports.Body = Body_default;
2144
2231
  exports.Button = Button_default;
2145
2232
  exports.Column = Column_default;
@@ -2160,6 +2247,8 @@ exports.Table = Table_default;
2160
2247
  exports.UnlayerProvider = UnlayerProvider;
2161
2248
  exports.Video = Video_default;
2162
2249
  exports.htmlToTextJson = htmlToTextJson;
2250
+ exports.registerElementsTool = registerElementsTool;
2251
+ exports.registerTool = registerTool;
2163
2252
  exports.renderRowToJson = renderRowToJson;
2164
2253
  exports.renderToHtml = renderToHtml;
2165
2254
  exports.renderToHtmlParts = renderToHtmlParts;