@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 +45 -1
- package/dist/index.cjs +288 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +153 -18
- package/dist/index.d.ts +153 -18
- package/dist/index.js +287 -58
- 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,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
|
-
|
|
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": {
|
|
@@ -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
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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 =
|
|
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
|
|
1164
|
-
.
|
|
1165
|
-
.u-row
|
|
1166
|
-
|
|
1167
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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, "&").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
|
+
}
|
|
1691
1836
|
function renderToPlainText(element, config) {
|
|
1692
|
-
const html =
|
|
1837
|
+
const html = renderBody(element, config);
|
|
1693
1838
|
return htmlToPlainText(html);
|
|
1694
1839
|
}
|
|
1695
1840
|
function renderToHtmlParts(element, config) {
|
|
1696
|
-
const body =
|
|
1841
|
+
const body = renderBody(element, config);
|
|
1697
1842
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
1698
|
-
const displayMode = element
|
|
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
|
|
1824
|
-
const
|
|
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:
|
|
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;
|