@templatical/import-unlayer 0.10.0 → 0.10.1

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/dist/index.js CHANGED
@@ -1,618 +1,597 @@
1
- // src/converter.ts
2
- import {
3
- createSectionBlock,
4
- createDefaultTemplateContent
5
- } from "@templatical/types";
6
-
7
- // src/block-mapper.ts
8
- import {
9
- createTitleBlock,
10
- createParagraphBlock,
11
- createImageBlock,
12
- createButtonBlock,
13
- createDividerBlock,
14
- createSpacerBlock,
15
- createHtmlBlock,
16
- createSocialIconsBlock,
17
- createMenuBlock,
18
- createVideoBlock,
19
- generateId
20
- } from "@templatical/types";
21
-
22
- // src/style-parser.ts
1
+ import { createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTitleBlock, createVideoBlock, generateId } from "@templatical/types";
2
+ //#region src/style-parser.ts
3
+ /**
4
+ * Parses CSS-like style values from Unlayer content values.
5
+ */
23
6
  function parsePxValue(value) {
24
- if (value === void 0 || value === null) return 0;
25
- if (typeof value === "number") return Math.round(value);
26
- const match = value.match(/^(-?\d+(?:\.\d+)?)\s*(?:px\s*)?$/);
27
- return match ? Math.round(parseFloat(match[1])) : 0;
7
+ if (value === void 0 || value === null) return 0;
8
+ if (typeof value === "number") return Math.round(value);
9
+ const match = value.match(/^(-?\d+(?:\.\d+)?)\s*(?:px\s*)?$/);
10
+ return match ? Math.round(parseFloat(match[1])) : 0;
28
11
  }
29
12
  function parseColor(value) {
30
- if (!value || value === "transparent") return "";
31
- const trimmed = value.trim();
32
- if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toLowerCase();
33
- if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
34
- const r = trimmed[1];
35
- const g = trimmed[2];
36
- const b = trimmed[3];
37
- return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
38
- }
39
- return trimmed;
13
+ if (!value || value === "transparent") return "";
14
+ const trimmed = value.trim();
15
+ if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toLowerCase();
16
+ if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) {
17
+ const r = trimmed[1];
18
+ const g = trimmed[2];
19
+ const b = trimmed[3];
20
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
21
+ }
22
+ return trimmed;
40
23
  }
41
24
  function parsePaddingShorthand(value) {
42
- if (!value) return { top: 0, right: 0, bottom: 0, left: 0 };
43
- const parts = value.trim().split(/\s+/);
44
- const values = parts.map((p) => parsePxValue(p));
45
- switch (values.length) {
46
- case 1:
47
- return {
48
- top: values[0],
49
- right: values[0],
50
- bottom: values[0],
51
- left: values[0]
52
- };
53
- case 2:
54
- return {
55
- top: values[0],
56
- right: values[1],
57
- bottom: values[0],
58
- left: values[1]
59
- };
60
- case 3:
61
- return {
62
- top: values[0],
63
- right: values[1],
64
- bottom: values[2],
65
- left: values[1]
66
- };
67
- default:
68
- return {
69
- top: values[0],
70
- right: values[1],
71
- bottom: values[2],
72
- left: values[3]
73
- };
74
- }
25
+ if (!value) return {
26
+ top: 0,
27
+ right: 0,
28
+ bottom: 0,
29
+ left: 0
30
+ };
31
+ const values = value.trim().split(/\s+/).map((p) => parsePxValue(p));
32
+ switch (values.length) {
33
+ case 1: return {
34
+ top: values[0],
35
+ right: values[0],
36
+ bottom: values[0],
37
+ left: values[0]
38
+ };
39
+ case 2: return {
40
+ top: values[0],
41
+ right: values[1],
42
+ bottom: values[0],
43
+ left: values[1]
44
+ };
45
+ case 3: return {
46
+ top: values[0],
47
+ right: values[1],
48
+ bottom: values[2],
49
+ left: values[1]
50
+ };
51
+ default: return {
52
+ top: values[0],
53
+ right: values[1],
54
+ bottom: values[2],
55
+ left: values[3]
56
+ };
57
+ }
75
58
  }
76
59
  function parseBorderObject(border) {
77
- if (!border) return { width: 0, style: "solid", color: "#000000" };
78
- return {
79
- width: parsePxValue(border.borderTopWidth),
80
- style: border.borderTopStyle || "solid",
81
- color: parseColor(border.borderTopColor) || "#000000"
82
- };
60
+ if (!border) return {
61
+ width: 0,
62
+ style: "solid",
63
+ color: "#000000"
64
+ };
65
+ return {
66
+ width: parsePxValue(border.borderTopWidth),
67
+ style: border.borderTopStyle || "solid",
68
+ color: parseColor(border.borderTopColor) || "#000000"
69
+ };
83
70
  }
84
71
  function parseWidthPercent(value) {
85
- if (!value) return 100;
86
- const match = value.match(/^(\d+(?:\.\d+)?)\s*%/);
87
- if (match) return Math.round(parseFloat(match[1]));
88
- return 100;
72
+ if (!value) return 100;
73
+ const match = value.match(/^(\d+(?:\.\d+)?)\s*%/);
74
+ if (match) return Math.round(parseFloat(match[1]));
75
+ return 100;
89
76
  }
90
77
  function parseFontFamily(value) {
91
- if (!value) return "";
92
- if (typeof value === "string") {
93
- return value.split(",")[0].trim().replace(/['"]/g, "");
94
- }
95
- if (value.value) return value.value.split(",")[0].trim().replace(/['"]/g, "");
96
- if (value.label) return value.label;
97
- return "";
78
+ if (!value) return "";
79
+ if (typeof value === "string") return value.split(",")[0].trim().replace(/['"]/g, "");
80
+ if (value.value) return value.value.split(",")[0].trim().replace(/['"]/g, "");
81
+ if (value.label) return value.label;
82
+ return "";
98
83
  }
99
-
100
- // src/block-mapper.ts
101
- var SOCIAL_PLATFORM_MAP = {
102
- facebook: "facebook",
103
- twitter: "twitter",
104
- x: "twitter",
105
- instagram: "instagram",
106
- linkedin: "linkedin",
107
- youtube: "youtube",
108
- tiktok: "tiktok",
109
- pinterest: "pinterest",
110
- email: "email",
111
- mail: "email",
112
- whatsapp: "whatsapp",
113
- telegram: "telegram",
114
- discord: "discord",
115
- snapchat: "snapchat",
116
- reddit: "reddit",
117
- github: "github",
118
- dribbble: "dribbble",
119
- behance: "behance"
84
+ //#endregion
85
+ //#region src/block-mapper.ts
86
+ const SOCIAL_PLATFORM_MAP = {
87
+ facebook: "facebook",
88
+ twitter: "twitter",
89
+ x: "twitter",
90
+ instagram: "instagram",
91
+ linkedin: "linkedin",
92
+ youtube: "youtube",
93
+ tiktok: "tiktok",
94
+ pinterest: "pinterest",
95
+ email: "email",
96
+ mail: "email",
97
+ whatsapp: "whatsapp",
98
+ telegram: "telegram",
99
+ discord: "discord",
100
+ snapchat: "snapchat",
101
+ reddit: "reddit",
102
+ github: "github",
103
+ dribbble: "dribbble",
104
+ behance: "behance"
120
105
  };
121
106
  function toAlign(value, fallback = "left") {
122
- if (value === "left" || value === "center" || value === "right") return value;
123
- return fallback;
107
+ if (value === "left" || value === "center" || value === "right") return value;
108
+ return fallback;
124
109
  }
110
+ /**
111
+ * Strip every `<…>` from a button label and reject any stray `<` or `>`
112
+ * left behind (e.g. a truncated `<script`). The original
113
+ * `value.replace(/<[^>]*>/g, "")` was both polynomial-ReDoS over
114
+ * `<<<<…` inputs and an incomplete sanitizer — an unterminated `<script`
115
+ * would survive the strip. Downstream HTML-escapes the label at render
116
+ * time, but stripping here keeps the imported JSON clean.
117
+ */
125
118
  function stripTagsPlain(text) {
126
- let out = "";
127
- let i = 0;
128
- while (i < text.length) {
129
- if (text[i] === "<") {
130
- const close = text.indexOf(">", i + 1);
131
- if (close === -1) {
132
- break;
133
- }
134
- i = close + 1;
135
- continue;
136
- }
137
- if (text[i] === ">") {
138
- i++;
139
- continue;
140
- }
141
- out += text[i];
142
- i++;
143
- }
144
- return out;
119
+ let out = "";
120
+ let i = 0;
121
+ while (i < text.length) {
122
+ if (text[i] === "<") {
123
+ const close = text.indexOf(">", i + 1);
124
+ if (close === -1) break;
125
+ i = close + 1;
126
+ continue;
127
+ }
128
+ if (text[i] === ">") {
129
+ i++;
130
+ continue;
131
+ }
132
+ out += text[i];
133
+ i++;
134
+ }
135
+ return out;
145
136
  }
146
137
  function toLineStyle(value, fallback = "solid") {
147
- if (value === "solid" || value === "dashed" || value === "dotted")
148
- return value;
149
- return fallback;
138
+ if (value === "solid" || value === "dashed" || value === "dotted") return value;
139
+ return fallback;
150
140
  }
151
141
  function makeStyles(values) {
152
- const padding = parsePaddingShorthand(values.containerPadding);
153
- return {
154
- padding
155
- };
142
+ return { padding: parsePaddingShorthand(values.containerPadding) };
156
143
  }
144
+ /**
145
+ * Apply Unlayer text-level styles as TipTap-compatible inline markup.
146
+ * Mirrors the BeeFree importer's helper but reads from Unlayer's flat
147
+ * values shape rather than a CSS style record.
148
+ */
157
149
  function inlineStylesToHtml(html, values) {
158
- const spanParts = [];
159
- const fontSize = parsePxValue(values.fontSize);
160
- if (fontSize && fontSize !== 16) spanParts.push(`font-size: ${fontSize}px`);
161
- const color = parseColor(values.color);
162
- if (color && color !== "#1a1a1a") spanParts.push(`color: ${color}`);
163
- const fontWeight = values.fontWeight;
164
- if (fontWeight !== void 0 && fontWeight !== null && String(fontWeight) !== "normal" && String(fontWeight) !== "400") {
165
- spanParts.push(`font-weight: ${fontWeight}`);
166
- }
167
- const fontFamily = parseFontFamily(values.fontFamily);
168
- if (fontFamily) spanParts.push(`font-family: ${fontFamily}`);
169
- const textAlign = values.textAlign;
170
- const pStyle = textAlign && textAlign !== "left" ? `text-align: ${textAlign}` : "";
171
- if (!pStyle && spanParts.length === 0) return html;
172
- const spanStyle = spanParts.join("; ");
173
- let result = html;
174
- if (pStyle) {
175
- result = result.replace(/<p style="([^"]*)">/g, `<p style="$1; ${pStyle}">`).replaceAll("<p>", `<p style="${pStyle}">`);
176
- }
177
- if (spanStyle) {
178
- result = wrapParagraphInner(result, spanStyle);
179
- }
180
- return result;
150
+ const spanParts = [];
151
+ const fontSize = parsePxValue(values.fontSize);
152
+ if (fontSize && fontSize !== 16) spanParts.push(`font-size: ${fontSize}px`);
153
+ const color = parseColor(values.color);
154
+ if (color && color !== "#1a1a1a") spanParts.push(`color: ${color}`);
155
+ const fontWeight = values.fontWeight;
156
+ if (fontWeight !== void 0 && fontWeight !== null && String(fontWeight) !== "normal" && String(fontWeight) !== "400") spanParts.push(`font-weight: ${fontWeight}`);
157
+ const fontFamily = parseFontFamily(values.fontFamily);
158
+ if (fontFamily) spanParts.push(`font-family: ${fontFamily}`);
159
+ const textAlign = values.textAlign;
160
+ const pStyle = textAlign && textAlign !== "left" ? `text-align: ${textAlign}` : "";
161
+ if (!pStyle && spanParts.length === 0) return html;
162
+ const spanStyle = spanParts.join("; ");
163
+ let result = html;
164
+ if (pStyle) result = result.replace(/<p style="([^"]*)">/g, `<p style="$1; ${pStyle}">`).replaceAll("<p>", `<p style="${pStyle}">`);
165
+ if (spanStyle) result = wrapParagraphInner(result, spanStyle);
166
+ return result;
181
167
  }
168
+ /**
169
+ * Wrap the inner content of every `<p …>…</p>` with `<span style="…">…</span>`.
170
+ *
171
+ * Hand-rolled linear scanner instead of `/<p([^>]*)>([\s\S]*?)<\/p>/g` because
172
+ * the regex is polynomial-ReDoS: the engine retries `[\s\S]*?<\/p>` at every
173
+ * `<p` start, so inputs like `<p>a<p>a<p>a…` (no `</p>` ever) cost O(n²).
174
+ * The scanner advances `i` monotonically via `indexOf`, keeping the work
175
+ * strictly O(n).
176
+ */
182
177
  function wrapParagraphInner(html, spanStyle) {
183
- let out = "";
184
- let i = 0;
185
- while (i < html.length) {
186
- const open = html.indexOf("<p", i);
187
- if (open === -1) {
188
- out += html.substring(i);
189
- break;
190
- }
191
- const afterTagName = html[open + 2];
192
- if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
193
- out += html.substring(i, open + 2);
194
- i = open + 2;
195
- continue;
196
- }
197
- const openEnd = html.indexOf(">", open + 2);
198
- if (openEnd === -1) {
199
- out += html.substring(i);
200
- break;
201
- }
202
- const closeStart = html.indexOf("</p>", openEnd + 1);
203
- if (closeStart === -1) {
204
- out += html.substring(i);
205
- break;
206
- }
207
- const inner = html.substring(openEnd + 1, closeStart);
208
- out += html.substring(i, openEnd + 1);
209
- out += `<span style="${spanStyle}">${inner}</span></p>`;
210
- i = closeStart + 4;
211
- }
212
- return out;
178
+ let out = "";
179
+ let i = 0;
180
+ while (i < html.length) {
181
+ const open = html.indexOf("<p", i);
182
+ if (open === -1) {
183
+ out += html.substring(i);
184
+ break;
185
+ }
186
+ const afterTagName = html[open + 2];
187
+ if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
188
+ out += html.substring(i, open + 2);
189
+ i = open + 2;
190
+ continue;
191
+ }
192
+ const openEnd = html.indexOf(">", open + 2);
193
+ if (openEnd === -1) {
194
+ out += html.substring(i);
195
+ break;
196
+ }
197
+ const closeStart = html.indexOf("</p>", openEnd + 1);
198
+ if (closeStart === -1) {
199
+ out += html.substring(i);
200
+ break;
201
+ }
202
+ const inner = html.substring(openEnd + 1, closeStart);
203
+ out += html.substring(i, openEnd + 1);
204
+ out += `<span style="${spanStyle}">${inner}</span></p>`;
205
+ i = closeStart + 4;
206
+ }
207
+ return out;
213
208
  }
214
209
  function ensureParagraphWrapped(html) {
215
- if (!html) return "<p></p>";
216
- if (/<p[\s>]/i.test(html)) return html;
217
- return `<p>${html}</p>`;
210
+ if (!html) return "<p></p>";
211
+ if (/<p[\s>]/i.test(html)) return html;
212
+ return `<p>${html}</p>`;
218
213
  }
219
214
  function convertText(values) {
220
- const html = ensureParagraphWrapped(values.text ?? "");
221
- return createParagraphBlock({
222
- content: inlineStylesToHtml(html, values),
223
- styles: makeStyles(values)
224
- });
215
+ return createParagraphBlock({
216
+ content: inlineStylesToHtml(ensureParagraphWrapped(values.text ?? ""), values),
217
+ styles: makeStyles(values)
218
+ });
225
219
  }
226
220
  function parseHeadingLevel(tag) {
227
- if (!tag) return 2;
228
- const match = tag.match(/^h(\d)$/i);
229
- if (match) {
230
- const num = Number(match[1]);
231
- if (num >= 1 && num <= 4) return num;
232
- }
233
- return 2;
221
+ if (!tag) return 2;
222
+ const match = tag.match(/^h(\d)$/i);
223
+ if (match) {
224
+ const num = Number(match[1]);
225
+ if (num >= 1 && num <= 4) return num;
226
+ }
227
+ return 2;
234
228
  }
235
229
  function convertHeading(values) {
236
- const text = values.text ?? "";
237
- const stripped = text.replace(/^<h\d[^>]*>|<\/h\d>$/gi, "");
238
- const content = stripped ? `<p>${stripped}</p>` : "<p></p>";
239
- return createTitleBlock({
240
- content,
241
- level: parseHeadingLevel(values.headingType),
242
- color: parseColor(values.color) || "#1a1a1a",
243
- textAlign: toAlign(values.textAlign),
244
- fontFamily: parseFontFamily(values.fontFamily) || void 0,
245
- styles: makeStyles(values)
246
- });
230
+ const stripped = (values.text ?? "").replace(/^<h\d[^>]*>|<\/h\d>$/gi, "");
231
+ return createTitleBlock({
232
+ content: stripped ? `<p>${stripped}</p>` : "<p></p>",
233
+ level: parseHeadingLevel(values.headingType),
234
+ color: parseColor(values.color) || "#1a1a1a",
235
+ textAlign: toAlign(values.textAlign),
236
+ fontFamily: parseFontFamily(values.fontFamily) || void 0,
237
+ styles: makeStyles(values)
238
+ });
247
239
  }
248
240
  function convertImage(values) {
249
- const src = values.src;
250
- const action = values.action?.values;
251
- return createImageBlock({
252
- src: src?.url || "",
253
- alt: values.altText || "",
254
- width: src?.width ? Math.round(src.width) : 600,
255
- align: toAlign(values.textAlign, "center"),
256
- linkUrl: action?.href || void 0,
257
- linkOpenInNewTab: action?.target === "_blank" || void 0,
258
- styles: makeStyles(values)
259
- });
241
+ const src = values.src;
242
+ const action = values.action?.values;
243
+ return createImageBlock({
244
+ src: src?.url || "",
245
+ alt: values.altText || "",
246
+ width: src?.width ? Math.round(src.width) : 600,
247
+ align: toAlign(values.textAlign, "center"),
248
+ linkUrl: action?.href || void 0,
249
+ linkOpenInNewTab: action?.target === "_blank" || void 0,
250
+ styles: makeStyles(values)
251
+ });
260
252
  }
261
253
  function convertButton(values) {
262
- const colors = values.buttonColors ?? {};
263
- const padding = values.padding ? parsePaddingShorthand(values.padding) : { top: 12, right: 24, bottom: 12, left: 24 };
264
- const label = stripTagsPlain(values.text ?? "Button");
265
- const linkValues = values.href?.values;
266
- return createButtonBlock({
267
- text: label,
268
- url: linkValues?.href || "#",
269
- openInNewTab: linkValues?.target === "_blank" || void 0,
270
- backgroundColor: parseColor(colors.backgroundColor) || "#4f46e5",
271
- textColor: parseColor(colors.color) || "#ffffff",
272
- borderRadius: parsePxValue(values.borderRadius),
273
- fontSize: parsePxValue(values.fontSize) || 16,
274
- fontFamily: parseFontFamily(values.fontFamily) || void 0,
275
- buttonPadding: padding,
276
- styles: makeStyles(values)
277
- });
254
+ const colors = values.buttonColors ?? {};
255
+ const padding = values.padding ? parsePaddingShorthand(values.padding) : {
256
+ top: 12,
257
+ right: 24,
258
+ bottom: 12,
259
+ left: 24
260
+ };
261
+ const label = stripTagsPlain(values.text ?? "Button");
262
+ const linkValues = values.href?.values;
263
+ return createButtonBlock({
264
+ text: label,
265
+ url: linkValues?.href || "#",
266
+ openInNewTab: linkValues?.target === "_blank" || void 0,
267
+ backgroundColor: parseColor(colors.backgroundColor) || "#4f46e5",
268
+ textColor: parseColor(colors.color) || "#ffffff",
269
+ borderRadius: parsePxValue(values.borderRadius),
270
+ fontSize: parsePxValue(values.fontSize) || 16,
271
+ fontFamily: parseFontFamily(values.fontFamily) || void 0,
272
+ buttonPadding: padding,
273
+ styles: makeStyles(values)
274
+ });
278
275
  }
279
276
  function convertDivider(values) {
280
- const border = parseBorderObject(values.border);
281
- return createDividerBlock({
282
- lineStyle: toLineStyle(border.style),
283
- color: border.color,
284
- thickness: border.width || 1,
285
- width: parseWidthPercent(values.width),
286
- styles: makeStyles(values)
287
- });
277
+ const border = parseBorderObject(values.border);
278
+ return createDividerBlock({
279
+ lineStyle: toLineStyle(border.style),
280
+ color: border.color,
281
+ thickness: border.width || 1,
282
+ width: parseWidthPercent(values.width),
283
+ styles: makeStyles(values)
284
+ });
288
285
  }
289
286
  function convertSpacer(values) {
290
- const padding = parsePaddingShorthand(values.containerPadding);
291
- const height = parsePxValue(values.height) || padding.top + padding.bottom || 24;
292
- return createSpacerBlock({
293
- height,
294
- styles: makeStyles(values)
295
- });
287
+ const padding = parsePaddingShorthand(values.containerPadding);
288
+ return createSpacerBlock({
289
+ height: parsePxValue(values.height) || padding.top + padding.bottom || 24,
290
+ styles: makeStyles(values)
291
+ });
296
292
  }
297
293
  function convertHtml(values) {
298
- return createHtmlBlock({
299
- content: values.html ?? "",
300
- styles: makeStyles(values)
301
- });
294
+ return createHtmlBlock({
295
+ content: values.html ?? "",
296
+ styles: makeStyles(values)
297
+ });
302
298
  }
303
299
  function convertSocial(values, warnings) {
304
- const iconList = values.icons?.icons ?? [];
305
- const icons = [];
306
- for (const unlayerIcon of iconList) {
307
- const id = (unlayerIcon.name ?? "").toLowerCase();
308
- const platform = SOCIAL_PLATFORM_MAP[id];
309
- if (!platform) {
310
- warnings.push(
311
- `Unrecognized social icon "${unlayerIcon.name || id}" was skipped.`
312
- );
313
- continue;
314
- }
315
- icons.push({
316
- id: generateId(),
317
- platform,
318
- url: unlayerIcon.url || "#"
319
- });
320
- }
321
- return createSocialIconsBlock({
322
- icons,
323
- align: toAlign(values.textAlign, "center"),
324
- styles: makeStyles(values)
325
- });
300
+ const iconList = values.icons?.icons ?? [];
301
+ const icons = [];
302
+ for (const unlayerIcon of iconList) {
303
+ const id = (unlayerIcon.name ?? "").toLowerCase();
304
+ const platform = SOCIAL_PLATFORM_MAP[id];
305
+ if (!platform) {
306
+ warnings.push(`Unrecognized social icon "${unlayerIcon.name || id}" was skipped.`);
307
+ continue;
308
+ }
309
+ icons.push({
310
+ id: generateId(),
311
+ platform,
312
+ url: unlayerIcon.url || "#"
313
+ });
314
+ }
315
+ return createSocialIconsBlock({
316
+ icons,
317
+ align: toAlign(values.textAlign, "center"),
318
+ styles: makeStyles(values)
319
+ });
326
320
  }
327
321
  function convertVideo(values) {
328
- return createVideoBlock({
329
- url: values.videoUrl || "",
330
- thumbnailUrl: values.thumbnailUrl || "",
331
- alt: values.altText || "",
332
- width: 600,
333
- align: toAlign(values.textAlign, "center"),
334
- styles: makeStyles(values)
335
- });
322
+ return createVideoBlock({
323
+ url: values.videoUrl || "",
324
+ thumbnailUrl: values.thumbnailUrl || "",
325
+ alt: values.altText || "",
326
+ width: 600,
327
+ align: toAlign(values.textAlign, "center"),
328
+ styles: makeStyles(values)
329
+ });
336
330
  }
337
331
  function convertMenu(values) {
338
- const menu = values.menu;
339
- const items = (menu?.items ?? []).map((item) => ({
340
- id: generateId(),
341
- text: item.text || "",
342
- url: item.link?.values?.href || "#",
343
- openInNewTab: item.link?.values?.target === "_blank",
344
- bold: false,
345
- underline: false
346
- }));
347
- return createMenuBlock({
348
- items,
349
- separator: values.separator || "|",
350
- separatorColor: "#999999",
351
- fontSize: parsePxValue(values.fontSize) || 14,
352
- color: parseColor(values.color) || "#1a1a1a",
353
- fontFamily: parseFontFamily(values.fontFamily) || void 0,
354
- textAlign: toAlign(values.textAlign, "center"),
355
- styles: makeStyles(values)
356
- });
332
+ return createMenuBlock({
333
+ items: (values.menu?.items ?? []).map((item) => ({
334
+ id: generateId(),
335
+ text: item.text || "",
336
+ url: item.link?.values?.href || "#",
337
+ openInNewTab: item.link?.values?.target === "_blank",
338
+ bold: false,
339
+ underline: false
340
+ })),
341
+ separator: values.separator || "|",
342
+ separatorColor: "#999999",
343
+ fontSize: parsePxValue(values.fontSize) || 14,
344
+ color: parseColor(values.color) || "#1a1a1a",
345
+ fontFamily: parseFontFamily(values.fontFamily) || void 0,
346
+ textAlign: toAlign(values.textAlign, "center"),
347
+ styles: makeStyles(values)
348
+ });
357
349
  }
358
350
  function convertHtmlFallback(content, comment) {
359
- const safe = comment.replace(/</g, "&lt;").replace(/>/g, "&gt;");
360
- return createHtmlBlock({
361
- content: `<div style="padding:12px;border:1px dashed #d1d5db;border-radius:6px;background:#fafafa;color:#6b7280;font-family:sans-serif;font-size:13px;">${safe}</div>`,
362
- styles: makeStyles(content.values)
363
- });
351
+ return createHtmlBlock({
352
+ content: `<div style="padding:12px;border:1px dashed #d1d5db;border-radius:6px;background:#fafafa;color:#6b7280;font-family:sans-serif;font-size:13px;">${comment.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>`,
353
+ styles: makeStyles(content.values)
354
+ });
364
355
  }
356
+ /**
357
+ * Converts a single Unlayer content node to a Templatical block.
358
+ * Returns the block and a report entry.
359
+ */
365
360
  function convertContent(content, warnings) {
366
- const type = content.type;
367
- const values = content.values ?? {};
368
- switch (type) {
369
- case "text":
370
- return {
371
- block: convertText(values),
372
- entry: {
373
- unlayerContentType: type,
374
- templaticalBlockType: "paragraph",
375
- status: "converted"
376
- }
377
- };
378
- case "heading":
379
- return {
380
- block: convertHeading(values),
381
- entry: {
382
- unlayerContentType: type,
383
- templaticalBlockType: "title",
384
- status: "converted"
385
- }
386
- };
387
- case "image":
388
- return {
389
- block: convertImage(values),
390
- entry: {
391
- unlayerContentType: type,
392
- templaticalBlockType: "image",
393
- status: "converted"
394
- }
395
- };
396
- case "button":
397
- return {
398
- block: convertButton(values),
399
- entry: {
400
- unlayerContentType: type,
401
- templaticalBlockType: "button",
402
- status: "converted"
403
- }
404
- };
405
- case "divider":
406
- return {
407
- block: convertDivider(values),
408
- entry: {
409
- unlayerContentType: type,
410
- templaticalBlockType: "divider",
411
- status: "converted"
412
- }
413
- };
414
- case "spacer":
415
- return {
416
- block: convertSpacer(values),
417
- entry: {
418
- unlayerContentType: type,
419
- templaticalBlockType: "spacer",
420
- status: "converted"
421
- }
422
- };
423
- case "html":
424
- return {
425
- block: convertHtml(values),
426
- entry: {
427
- unlayerContentType: type,
428
- templaticalBlockType: "html",
429
- status: "converted"
430
- }
431
- };
432
- case "menu":
433
- return {
434
- block: convertMenu(values),
435
- entry: {
436
- unlayerContentType: type,
437
- templaticalBlockType: "menu",
438
- status: "approximated",
439
- note: "Menu styles map approximately; review spacing and separator color."
440
- }
441
- };
442
- case "social":
443
- return {
444
- block: convertSocial(values, warnings),
445
- entry: {
446
- unlayerContentType: type,
447
- templaticalBlockType: "social",
448
- status: "converted"
449
- }
450
- };
451
- case "video":
452
- return {
453
- block: convertVideo(values),
454
- entry: {
455
- unlayerContentType: type,
456
- templaticalBlockType: "video",
457
- status: "converted"
458
- }
459
- };
460
- case "timer":
461
- return {
462
- block: convertHtmlFallback(
463
- content,
464
- "Unlayer timer block: rebuild manually in Templatical"
465
- ),
466
- entry: {
467
- unlayerContentType: type,
468
- templaticalBlockType: "html",
469
- status: "html-fallback",
470
- note: "Timer modules have no direct Templatical equivalent; placeholder HTML inserted."
471
- }
472
- };
473
- case "form":
474
- return {
475
- block: convertHtmlFallback(
476
- content,
477
- "Unlayer form block: not supported in Templatical (most email clients block form submission)"
478
- ),
479
- entry: {
480
- unlayerContentType: type,
481
- templaticalBlockType: null,
482
- status: "skipped",
483
- note: "Unlayer forms have no Templatical equivalent and are skipped. Most email clients block form submission anyway."
484
- }
485
- };
486
- default:
487
- return {
488
- block: convertHtmlFallback(
489
- content,
490
- `Unsupported Unlayer content type: ${type}`
491
- ),
492
- entry: {
493
- unlayerContentType: type,
494
- templaticalBlockType: "html",
495
- status: "html-fallback",
496
- note: `Unknown content type "${type}" converted to HTML block.`
497
- }
498
- };
499
- }
361
+ const type = content.type;
362
+ const values = content.values ?? {};
363
+ switch (type) {
364
+ case "text": return {
365
+ block: convertText(values),
366
+ entry: {
367
+ unlayerContentType: type,
368
+ templaticalBlockType: "paragraph",
369
+ status: "converted"
370
+ }
371
+ };
372
+ case "heading": return {
373
+ block: convertHeading(values),
374
+ entry: {
375
+ unlayerContentType: type,
376
+ templaticalBlockType: "title",
377
+ status: "converted"
378
+ }
379
+ };
380
+ case "image": return {
381
+ block: convertImage(values),
382
+ entry: {
383
+ unlayerContentType: type,
384
+ templaticalBlockType: "image",
385
+ status: "converted"
386
+ }
387
+ };
388
+ case "button": return {
389
+ block: convertButton(values),
390
+ entry: {
391
+ unlayerContentType: type,
392
+ templaticalBlockType: "button",
393
+ status: "converted"
394
+ }
395
+ };
396
+ case "divider": return {
397
+ block: convertDivider(values),
398
+ entry: {
399
+ unlayerContentType: type,
400
+ templaticalBlockType: "divider",
401
+ status: "converted"
402
+ }
403
+ };
404
+ case "spacer": return {
405
+ block: convertSpacer(values),
406
+ entry: {
407
+ unlayerContentType: type,
408
+ templaticalBlockType: "spacer",
409
+ status: "converted"
410
+ }
411
+ };
412
+ case "html": return {
413
+ block: convertHtml(values),
414
+ entry: {
415
+ unlayerContentType: type,
416
+ templaticalBlockType: "html",
417
+ status: "converted"
418
+ }
419
+ };
420
+ case "menu": return {
421
+ block: convertMenu(values),
422
+ entry: {
423
+ unlayerContentType: type,
424
+ templaticalBlockType: "menu",
425
+ status: "approximated",
426
+ note: "Menu styles map approximately; review spacing and separator color."
427
+ }
428
+ };
429
+ case "social": return {
430
+ block: convertSocial(values, warnings),
431
+ entry: {
432
+ unlayerContentType: type,
433
+ templaticalBlockType: "social",
434
+ status: "converted"
435
+ }
436
+ };
437
+ case "video": return {
438
+ block: convertVideo(values),
439
+ entry: {
440
+ unlayerContentType: type,
441
+ templaticalBlockType: "video",
442
+ status: "converted"
443
+ }
444
+ };
445
+ case "timer": return {
446
+ block: convertHtmlFallback(content, "Unlayer timer block: rebuild manually in Templatical"),
447
+ entry: {
448
+ unlayerContentType: type,
449
+ templaticalBlockType: "html",
450
+ status: "html-fallback",
451
+ note: "Timer modules have no direct Templatical equivalent; placeholder HTML inserted."
452
+ }
453
+ };
454
+ case "form": return {
455
+ block: convertHtmlFallback(content, "Unlayer form block: not supported in Templatical (most email clients block form submission)"),
456
+ entry: {
457
+ unlayerContentType: type,
458
+ templaticalBlockType: null,
459
+ status: "skipped",
460
+ note: "Unlayer forms have no Templatical equivalent and are skipped. Most email clients block form submission anyway."
461
+ }
462
+ };
463
+ default: return {
464
+ block: convertHtmlFallback(content, `Unsupported Unlayer content type: ${type}`),
465
+ entry: {
466
+ unlayerContentType: type,
467
+ templaticalBlockType: "html",
468
+ status: "html-fallback",
469
+ note: `Unknown content type "${type}" converted to HTML block.`
470
+ }
471
+ };
472
+ }
500
473
  }
501
-
502
- // src/converter.ts
474
+ //#endregion
475
+ //#region src/converter.ts
503
476
  function resolveColumnLayout(cells, warnings) {
504
- if (cells.length <= 1) return "1";
505
- if (cells.length === 3) return "3";
506
- if (cells.length === 2) {
507
- const left = cells[0] ?? 1;
508
- const right = cells[1] ?? 1;
509
- const total = left + right;
510
- const ratio = left / total;
511
- if (ratio > 0.58) return "2-1";
512
- if (ratio < 0.42) return "1-2";
513
- return "2";
514
- }
515
- warnings.push(
516
- `Row with ${cells.length} columns was flattened to a single column. Unlayer supports arbitrary columns, but Templatical supports up to 3.`
517
- );
518
- return "1";
477
+ if (cells.length <= 1) return "1";
478
+ if (cells.length === 3) return "3";
479
+ if (cells.length === 2) {
480
+ const left = cells[0] ?? 1;
481
+ const ratio = left / (left + (cells[1] ?? 1));
482
+ if (ratio > .58) return "2-1";
483
+ if (ratio < .42) return "1-2";
484
+ return "2";
485
+ }
486
+ warnings.push(`Row with ${cells.length} columns was flattened to a single column. Unlayer supports arbitrary columns, but Templatical supports up to 3.`);
487
+ return "1";
519
488
  }
489
+ /**
490
+ * Converts all contents in a column to Templatical blocks.
491
+ */
520
492
  function convertColumnContents(column, entries, warnings) {
521
- const blocks = [];
522
- for (const content of column.contents ?? []) {
523
- const { block, entry } = convertContent(content, warnings);
524
- blocks.push(block);
525
- entries.push(entry);
526
- }
527
- return blocks;
493
+ const blocks = [];
494
+ for (const content of column.contents ?? []) {
495
+ const { block, entry } = convertContent(content, warnings);
496
+ blocks.push(block);
497
+ entries.push(entry);
498
+ }
499
+ return blocks;
528
500
  }
501
+ /**
502
+ * Processes a single Unlayer row into one or more Templatical blocks.
503
+ */
529
504
  function processRow(row, entries, warnings) {
530
- const columns = row.columns;
531
- if (!columns || columns.length === 0) return [];
532
- const cells = row.cells ?? columns.map(() => 1);
533
- const layout = resolveColumnLayout(cells, warnings);
534
- let children;
535
- if (layout === "1") {
536
- const merged = [];
537
- for (const column of columns) {
538
- merged.push(...convertColumnContents(column, entries, warnings));
539
- }
540
- children = [merged];
541
- } else {
542
- children = columns.map(
543
- (col) => convertColumnContents(col, entries, warnings)
544
- );
545
- }
546
- const rowBg = parseColor(row.values?.backgroundColor);
547
- const padding = parsePaddingShorthand(row.values?.padding);
548
- const section = createSectionBlock({
549
- columns: layout,
550
- children,
551
- styles: {
552
- padding,
553
- ...rowBg ? { backgroundColor: rowBg } : {}
554
- }
555
- });
556
- return [section];
505
+ const columns = row.columns;
506
+ if (!columns || columns.length === 0) return [];
507
+ const layout = resolveColumnLayout(row.cells ?? columns.map(() => 1), warnings);
508
+ let children;
509
+ if (layout === "1") {
510
+ const merged = [];
511
+ for (const column of columns) merged.push(...convertColumnContents(column, entries, warnings));
512
+ children = [merged];
513
+ } else children = columns.map((col) => convertColumnContents(col, entries, warnings));
514
+ const rowBg = parseColor(row.values?.backgroundColor);
515
+ const padding = parsePaddingShorthand(row.values?.padding);
516
+ return [createSectionBlock({
517
+ columns: layout,
518
+ children,
519
+ styles: {
520
+ padding,
521
+ ...rowBg ? { backgroundColor: rowBg } : {}
522
+ }
523
+ })];
557
524
  }
525
+ /**
526
+ * Extracts template-level settings from the Unlayer body values.
527
+ */
558
528
  function extractSettings(template) {
559
- const values = template.body?.values ?? {};
560
- const width = parsePxValue(values.contentWidth);
561
- const bgColor = parseColor(values.backgroundColor) || "#ffffff";
562
- const fontFamily = parseFontFamily(values.fontFamily) || "Arial";
563
- return {
564
- width: width || 600,
565
- backgroundColor: bgColor,
566
- fontFamily,
567
- locale: "en"
568
- };
529
+ const values = template.body?.values ?? {};
530
+ const width = parsePxValue(values.contentWidth);
531
+ const bgColor = parseColor(values.backgroundColor) || "#ffffff";
532
+ const fontFamily = parseFontFamily(values.fontFamily) || "Arial";
533
+ return {
534
+ width: width || 600,
535
+ backgroundColor: bgColor,
536
+ fontFamily,
537
+ locale: "en"
538
+ };
569
539
  }
540
+ /**
541
+ * Converts an Unlayer design JSON to Templatical TemplateContent.
542
+ *
543
+ * @param template - The parsed Unlayer JSON object (the result of `editor.saveDesign(...)`)
544
+ * @returns An ImportResult with the converted content and a detailed report
545
+ *
546
+ * @example
547
+ * ```ts
548
+ * import { convertUnlayerTemplate } from '@templatical/import-unlayer';
549
+ *
550
+ * const unlayerJson = JSON.parse(fileContent);
551
+ * const { content, report } = convertUnlayerTemplate(unlayerJson);
552
+ *
553
+ * const editor = init({ container: '#editor', content });
554
+ *
555
+ * console.log(report.summary);
556
+ * console.log(report.warnings);
557
+ * ```
558
+ */
570
559
  function convertUnlayerTemplate(template) {
571
- if (!template?.body?.rows) {
572
- throw new Error(
573
- "Invalid Unlayer template: missing body.rows. Ensure you are passing a valid Unlayer JSON design (the output of editor.saveDesign)."
574
- );
575
- }
576
- const entries = [];
577
- const warnings = [];
578
- const blocks = [];
579
- const headers = template.body.headers ?? [];
580
- const footers = template.body.footers ?? [];
581
- if (headers.length > 0) {
582
- warnings.push(
583
- `${headers.length} Unlayer header row(s) were imported as regular rows at the top of the template.`
584
- );
585
- for (const row of headers) {
586
- blocks.push(...processRow(row, entries, warnings));
587
- }
588
- }
589
- for (const row of template.body.rows) {
590
- blocks.push(...processRow(row, entries, warnings));
591
- }
592
- if (footers.length > 0) {
593
- warnings.push(
594
- `${footers.length} Unlayer footer row(s) were imported as regular rows at the bottom of the template.`
595
- );
596
- for (const row of footers) {
597
- blocks.push(...processRow(row, entries, warnings));
598
- }
599
- }
600
- const content = {
601
- ...createDefaultTemplateContent(),
602
- blocks,
603
- settings: extractSettings(template)
604
- };
605
- const summary = {
606
- total: entries.length,
607
- converted: entries.filter((e) => e.status === "converted").length,
608
- approximated: entries.filter((e) => e.status === "approximated").length,
609
- htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
610
- skipped: entries.filter((e) => e.status === "skipped").length
611
- };
612
- const report = { entries, warnings, summary };
613
- return { content, report };
560
+ if (!template?.body?.rows) throw new Error("Invalid Unlayer template: missing body.rows. Ensure you are passing a valid Unlayer JSON design (the output of editor.saveDesign).");
561
+ const entries = [];
562
+ const warnings = [];
563
+ const blocks = [];
564
+ const headers = template.body.headers ?? [];
565
+ const footers = template.body.footers ?? [];
566
+ if (headers.length > 0) {
567
+ warnings.push(`${headers.length} Unlayer header row(s) were imported as regular rows at the top of the template.`);
568
+ for (const row of headers) blocks.push(...processRow(row, entries, warnings));
569
+ }
570
+ for (const row of template.body.rows) blocks.push(...processRow(row, entries, warnings));
571
+ if (footers.length > 0) {
572
+ warnings.push(`${footers.length} Unlayer footer row(s) were imported as regular rows at the bottom of the template.`);
573
+ for (const row of footers) blocks.push(...processRow(row, entries, warnings));
574
+ }
575
+ return {
576
+ content: {
577
+ ...createDefaultTemplateContent(),
578
+ blocks,
579
+ settings: extractSettings(template)
580
+ },
581
+ report: {
582
+ entries,
583
+ warnings,
584
+ summary: {
585
+ total: entries.length,
586
+ converted: entries.filter((e) => e.status === "converted").length,
587
+ approximated: entries.filter((e) => e.status === "approximated").length,
588
+ htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
589
+ skipped: entries.filter((e) => e.status === "skipped").length
590
+ }
591
+ }
592
+ };
614
593
  }
615
- export {
616
- convertUnlayerTemplate
617
- };
594
+ //#endregion
595
+ export { convertUnlayerTemplate };
596
+
618
597
  //# sourceMappingURL=index.js.map