@templatical/import-html 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.d.ts +22 -21
- package/dist/index.js +895 -803
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,868 +1,960 @@
|
|
|
1
|
-
// src/converter.ts
|
|
2
1
|
import { load } from "cheerio";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createParagraphBlock, createSectionBlock, createSpacerBlock, createTitleBlock } from "@templatical/types";
|
|
3
|
+
//#region src/style-parser.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parses a CSS `style="..."` attribute string into a flat key/value record.
|
|
6
|
+
* Keys are lowercased; values are trimmed. Quotes around values are not stripped.
|
|
7
|
+
*/
|
|
9
8
|
function parseStyleAttribute(styleAttr) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
9
|
+
const result = {};
|
|
10
|
+
if (!styleAttr) return result;
|
|
11
|
+
for (const decl of styleAttr.split(";")) {
|
|
12
|
+
const idx = decl.indexOf(":");
|
|
13
|
+
if (idx === -1) continue;
|
|
14
|
+
const key = decl.slice(0, idx).trim().toLowerCase();
|
|
15
|
+
const value = decl.slice(idx + 1).trim();
|
|
16
|
+
if (key && value) result[key] = value;
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Serializes a flat key/value record back to a `style` attribute string.
|
|
22
|
+
*/
|
|
21
23
|
function serializeStyleAttribute(styles) {
|
|
22
|
-
|
|
24
|
+
return Object.entries(styles).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
23
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Parses a px-like CSS value (`"12px"`, `"12"`, `12`) into a rounded integer.
|
|
28
|
+
* Returns 0 for missing or unparseable input. Ignores em/% units.
|
|
29
|
+
*/
|
|
24
30
|
function parsePxValue(value) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
31
|
+
if (value === void 0 || value === null || value === "") return 0;
|
|
32
|
+
if (typeof value === "number") return Math.round(value);
|
|
33
|
+
const match = value.match(/^(-?\d+(?:\.\d+)?)\s*(?:px)?\s*$/);
|
|
34
|
+
return match ? Math.round(parseFloat(match[1])) : 0;
|
|
35
|
+
}
|
|
36
|
+
const NAMED_COLORS = {
|
|
37
|
+
black: "#000000",
|
|
38
|
+
white: "#ffffff",
|
|
39
|
+
red: "#ff0000",
|
|
40
|
+
green: "#008000",
|
|
41
|
+
blue: "#0000ff",
|
|
42
|
+
yellow: "#ffff00",
|
|
43
|
+
cyan: "#00ffff",
|
|
44
|
+
magenta: "#ff00ff",
|
|
45
|
+
gray: "#808080",
|
|
46
|
+
grey: "#808080",
|
|
47
|
+
silver: "#c0c0c0",
|
|
48
|
+
maroon: "#800000",
|
|
49
|
+
olive: "#808000",
|
|
50
|
+
lime: "#00ff00",
|
|
51
|
+
aqua: "#00ffff",
|
|
52
|
+
teal: "#008080",
|
|
53
|
+
navy: "#000080",
|
|
54
|
+
fuchsia: "#ff00ff",
|
|
55
|
+
purple: "#800080",
|
|
56
|
+
orange: "#ffa500",
|
|
57
|
+
pink: "#ffc0cb"
|
|
52
58
|
};
|
|
53
59
|
function rgbToHex(r, g, b) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
60
|
+
const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
|
|
61
|
+
const hex = (n) => clamp(n).toString(16).padStart(2, "0");
|
|
62
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Normalizes a CSS color value to a 6-digit lowercase hex string.
|
|
66
|
+
* - 3-digit hex expands to 6-digit
|
|
67
|
+
* - rgb()/rgba() converts to hex (alpha is dropped)
|
|
68
|
+
* - Named colors map via lookup
|
|
69
|
+
* - "transparent" / unknown returns ""
|
|
70
|
+
*/
|
|
58
71
|
function parseColor(value) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
parseInt(rgbMatch[3], 10)
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
if (NAMED_COLORS[trimmed]) return NAMED_COLORS[trimmed];
|
|
81
|
-
return "";
|
|
82
|
-
}
|
|
72
|
+
if (!value) return "";
|
|
73
|
+
const trimmed = value.trim().toLowerCase();
|
|
74
|
+
if (trimmed === "transparent" || trimmed === "inherit" || trimmed === "none") return "";
|
|
75
|
+
if (/^#[0-9a-f]{6}$/.test(trimmed)) return trimmed;
|
|
76
|
+
if (/^#[0-9a-f]{3}$/.test(trimmed)) {
|
|
77
|
+
const r = trimmed[1];
|
|
78
|
+
const g = trimmed[2];
|
|
79
|
+
const b = trimmed[3];
|
|
80
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
81
|
+
}
|
|
82
|
+
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/);
|
|
83
|
+
if (rgbMatch) return rgbToHex(parseInt(rgbMatch[1], 10), parseInt(rgbMatch[2], 10), parseInt(rgbMatch[3], 10));
|
|
84
|
+
if (NAMED_COLORS[trimmed]) return NAMED_COLORS[trimmed];
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parses a CSS `padding` shorthand (1-4 values) into a SpacingValue.
|
|
89
|
+
*/
|
|
83
90
|
function parsePaddingShorthand(value) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
91
|
+
if (!value) return {
|
|
92
|
+
top: 0,
|
|
93
|
+
right: 0,
|
|
94
|
+
bottom: 0,
|
|
95
|
+
left: 0
|
|
96
|
+
};
|
|
97
|
+
const values = value.trim().split(/\s+/).map((p) => parsePxValue(p));
|
|
98
|
+
switch (values.length) {
|
|
99
|
+
case 1: return {
|
|
100
|
+
top: values[0],
|
|
101
|
+
right: values[0],
|
|
102
|
+
bottom: values[0],
|
|
103
|
+
left: values[0]
|
|
104
|
+
};
|
|
105
|
+
case 2: return {
|
|
106
|
+
top: values[0],
|
|
107
|
+
right: values[1],
|
|
108
|
+
bottom: values[0],
|
|
109
|
+
left: values[1]
|
|
110
|
+
};
|
|
111
|
+
case 3: return {
|
|
112
|
+
top: values[0],
|
|
113
|
+
right: values[1],
|
|
114
|
+
bottom: values[2],
|
|
115
|
+
left: values[1]
|
|
116
|
+
};
|
|
117
|
+
default: return {
|
|
118
|
+
top: values[0],
|
|
119
|
+
right: values[1],
|
|
120
|
+
bottom: values[2],
|
|
121
|
+
left: values[3]
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Reads CSS padding from a style record, preferring the longhand props
|
|
127
|
+
* (padding-top/right/bottom/left) and falling back to the `padding` shorthand.
|
|
128
|
+
*/
|
|
118
129
|
function readPaddingFromStyles(styles) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
130
|
+
const shorthand = parsePaddingShorthand(styles.padding);
|
|
131
|
+
return {
|
|
132
|
+
top: parsePxValue(styles["padding-top"]) || shorthand.top,
|
|
133
|
+
right: parsePxValue(styles["padding-right"]) || shorthand.right,
|
|
134
|
+
bottom: parsePxValue(styles["padding-bottom"]) || shorthand.bottom,
|
|
135
|
+
left: parsePxValue(styles["padding-left"]) || shorthand.left
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Strips quotes and returns the first font in a font-family stack.
|
|
140
|
+
*/
|
|
127
141
|
function parseFontFamily(value) {
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
if (!value) return "";
|
|
143
|
+
return value.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
|
|
130
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Normalizes a font-weight value to a string CSS keyword/number that
|
|
147
|
+
* the editor accepts. Returns "" when the value is the default (normal/400).
|
|
148
|
+
*/
|
|
131
149
|
function parseFontWeight(value) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
150
|
+
if (!value) return "";
|
|
151
|
+
const trimmed = value.trim().toLowerCase();
|
|
152
|
+
if (trimmed === "normal" || trimmed === "400") return "";
|
|
153
|
+
return trimmed;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Parses CSS text-align to one of the allowed editor alignments.
|
|
157
|
+
*/
|
|
137
158
|
function parseAlignment(value, fallback = "left") {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
159
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
160
|
+
if (v === "left" || v === "center" || v === "right") return v;
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Parses a CSS `border` shorthand (`"1px solid #ccc"`) into width/style/color.
|
|
165
|
+
* Order-tolerant: each token is classified by content.
|
|
166
|
+
*/
|
|
142
167
|
function parseBorderShorthand(value) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
const fallback = {
|
|
169
|
+
width: 0,
|
|
170
|
+
style: "solid",
|
|
171
|
+
color: "#000000"
|
|
172
|
+
};
|
|
173
|
+
if (!value) return fallback;
|
|
174
|
+
const styleKeywords = new Set([
|
|
175
|
+
"none",
|
|
176
|
+
"hidden",
|
|
177
|
+
"dotted",
|
|
178
|
+
"dashed",
|
|
179
|
+
"solid",
|
|
180
|
+
"double",
|
|
181
|
+
"groove",
|
|
182
|
+
"ridge",
|
|
183
|
+
"inset",
|
|
184
|
+
"outset"
|
|
185
|
+
]);
|
|
186
|
+
let width = 0;
|
|
187
|
+
let style = "solid";
|
|
188
|
+
let color = "#000000";
|
|
189
|
+
for (const token of value.trim().split(/\s+/)) {
|
|
190
|
+
const lower = token.toLowerCase();
|
|
191
|
+
if (styleKeywords.has(lower)) style = lower;
|
|
192
|
+
else if (/^-?\d+(?:\.\d+)?(?:px)?$/i.test(lower)) width = parsePxValue(lower);
|
|
193
|
+
else {
|
|
194
|
+
const c = parseColor(lower);
|
|
195
|
+
if (c) color = c;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
width,
|
|
200
|
+
style,
|
|
201
|
+
color
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/css-resolver.ts
|
|
206
|
+
/**
|
|
207
|
+
* Strips all CSS comments. Handles nested-looking content safely.
|
|
208
|
+
*/
|
|
175
209
|
function stripComments(css) {
|
|
176
|
-
|
|
177
|
-
}
|
|
210
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Strips at-rule blocks (@media, @font-face, @keyframes, @supports, etc.)
|
|
214
|
+
* and their nested content. Leaves top-level rules in place.
|
|
215
|
+
*
|
|
216
|
+
* Email HTML rarely benefits from @media (we render at one viewport),
|
|
217
|
+
* and resolving it onto elements would not be visually faithful anyway.
|
|
218
|
+
*/
|
|
178
219
|
function stripAtRules(css) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
let result = "";
|
|
221
|
+
let i = 0;
|
|
222
|
+
while (i < css.length) if (css[i] === "@") {
|
|
223
|
+
const semiIdx = css.indexOf(";", i);
|
|
224
|
+
const braceIdx = css.indexOf("{", i);
|
|
225
|
+
if (braceIdx === -1 || semiIdx !== -1 && semiIdx < braceIdx) {
|
|
226
|
+
i = semiIdx === -1 ? css.length : semiIdx + 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let depth = 0;
|
|
230
|
+
let j = braceIdx;
|
|
231
|
+
for (; j < css.length; j++) if (css[j] === "{") depth++;
|
|
232
|
+
else if (css[j] === "}") {
|
|
233
|
+
depth--;
|
|
234
|
+
if (depth === 0) {
|
|
235
|
+
j++;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
i = j;
|
|
240
|
+
} else {
|
|
241
|
+
result += css[i];
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Parses a CSS declarations block (`color: red; font-size: 14px`) into
|
|
248
|
+
* a flat record. `!important` markers are dropped.
|
|
249
|
+
*/
|
|
209
250
|
function parseDeclarations(text) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
251
|
+
const result = {};
|
|
252
|
+
for (const decl of text.split(";")) {
|
|
253
|
+
const idx = decl.indexOf(":");
|
|
254
|
+
if (idx === -1) continue;
|
|
255
|
+
const key = decl.slice(0, idx).trim().toLowerCase();
|
|
256
|
+
let value = decl.slice(idx + 1).trim();
|
|
257
|
+
value = value.replace(/!important\s*$/i, "").trim();
|
|
258
|
+
if (key && value) result[key] = value;
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A selector is "supported" by cheerio's matcher if it has no pseudo-classes
|
|
264
|
+
* or pseudo-elements. Resolving e.g. `a:hover` onto an inline style would be
|
|
265
|
+
* wrong (it would always apply), so we skip such rules entirely.
|
|
266
|
+
*/
|
|
221
267
|
function isSupportedSelector(selector) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
268
|
+
if (!selector) return false;
|
|
269
|
+
if (selector.includes(":")) return false;
|
|
270
|
+
if (selector.includes("@")) return false;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Parses the full content of one or more `<style>` tags into a list of rules.
|
|
275
|
+
* Skips at-rules and selectors with pseudo-classes.
|
|
276
|
+
*/
|
|
227
277
|
function parseStyleSheet(css) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
278
|
+
const rules = [];
|
|
279
|
+
const cleaned = stripAtRules(stripComments(css));
|
|
280
|
+
const blockRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
281
|
+
let match;
|
|
282
|
+
while ((match = blockRe.exec(cleaned)) !== null) {
|
|
283
|
+
const selectorPart = match[1].trim();
|
|
284
|
+
const declarationPart = match[2];
|
|
285
|
+
if (!selectorPart) continue;
|
|
286
|
+
const selectors = selectorPart.split(",").map((s) => s.trim()).filter(isSupportedSelector);
|
|
287
|
+
if (selectors.length === 0) continue;
|
|
288
|
+
const declarations = parseDeclarations(declarationPart);
|
|
289
|
+
if (Object.keys(declarations).length === 0) continue;
|
|
290
|
+
rules.push({
|
|
291
|
+
selectors,
|
|
292
|
+
declarations
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return rules;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Reads all `<style>` tags from the document, parses them into rules,
|
|
299
|
+
* applies each rule's declarations to matching elements (merging with
|
|
300
|
+
* existing inline `style=""` attributes — inline always wins), and removes
|
|
301
|
+
* the `<style>` tags from the document.
|
|
302
|
+
*
|
|
303
|
+
* No specificity is computed; rules are applied in source order, with later
|
|
304
|
+
* rules overriding earlier ones. Inline styles always override resolved
|
|
305
|
+
* rules. This is sufficient for typical email HTML, where authors already
|
|
306
|
+
* inline most styles.
|
|
307
|
+
*/
|
|
244
308
|
function resolveCssStyles($) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
309
|
+
const styleTags = $("style");
|
|
310
|
+
if (styleTags.length === 0) return;
|
|
311
|
+
const allRules = [];
|
|
312
|
+
styleTags.each((_, el) => {
|
|
313
|
+
const css = $(el).text();
|
|
314
|
+
if (css) allRules.push(...parseStyleSheet(css));
|
|
315
|
+
});
|
|
316
|
+
const inlineByEl = /* @__PURE__ */ new WeakMap();
|
|
317
|
+
$("[style]").each((_, el) => {
|
|
318
|
+
inlineByEl.set(el, parseStyleAttribute($(el).attr("style")));
|
|
319
|
+
});
|
|
320
|
+
const resolvedByEl = /* @__PURE__ */ new WeakMap();
|
|
321
|
+
for (const rule of allRules) for (const selector of rule.selectors) {
|
|
322
|
+
let matched;
|
|
323
|
+
try {
|
|
324
|
+
matched = $(selector);
|
|
325
|
+
} catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
matched.each((_, el) => {
|
|
329
|
+
const key = el;
|
|
330
|
+
const current = resolvedByEl.get(key) ?? {};
|
|
331
|
+
for (const [k, v] of Object.entries(rule.declarations)) current[k] = v;
|
|
332
|
+
resolvedByEl.set(key, current);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
$("*").each((_, el) => {
|
|
336
|
+
const key = el;
|
|
337
|
+
const resolved = resolvedByEl.get(key);
|
|
338
|
+
if (!resolved) return;
|
|
339
|
+
const inline = inlineByEl.get(key) ?? {};
|
|
340
|
+
const merged = { ...resolved };
|
|
341
|
+
for (const [k, v] of Object.entries(inline)) merged[k] = v;
|
|
342
|
+
$(el).attr("style", serializeStyleAttribute(merged));
|
|
343
|
+
});
|
|
344
|
+
styleTags.remove();
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region src/block-mapper.ts
|
|
348
|
+
const HEADING_TAGS = new Set([
|
|
349
|
+
"h1",
|
|
350
|
+
"h2",
|
|
351
|
+
"h3",
|
|
352
|
+
"h4",
|
|
353
|
+
"h5",
|
|
354
|
+
"h6"
|
|
355
|
+
]);
|
|
356
|
+
const TEXT_TAGS = new Set([
|
|
357
|
+
"p",
|
|
358
|
+
"span",
|
|
359
|
+
"div"
|
|
360
|
+
]);
|
|
361
|
+
function emptyPadding$2() {
|
|
362
|
+
return {
|
|
363
|
+
top: 0,
|
|
364
|
+
right: 0,
|
|
365
|
+
bottom: 0,
|
|
366
|
+
left: 0
|
|
367
|
+
};
|
|
301
368
|
}
|
|
302
369
|
function tagOf(el) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return "";
|
|
370
|
+
if ("tagName" in el && typeof el.tagName === "string") return el.tagName.toLowerCase();
|
|
371
|
+
return "";
|
|
306
372
|
}
|
|
307
|
-
function getStyles($el) {
|
|
308
|
-
|
|
373
|
+
function getStyles$1($el) {
|
|
374
|
+
return parseStyleAttribute($el.attr("style"));
|
|
309
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Returns the inner HTML of `$el`.
|
|
378
|
+
*/
|
|
310
379
|
function getInnerHtml($el) {
|
|
311
|
-
|
|
380
|
+
return $el.html() ?? "";
|
|
312
381
|
}
|
|
313
382
|
function ensureParagraphWrapped(html) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
383
|
+
if (!html.trim()) return "<p></p>";
|
|
384
|
+
if (/<(p|h[1-6]|ul|ol|blockquote)[\s>]/i.test(html)) return html;
|
|
385
|
+
return `<p>${html}</p>`;
|
|
317
386
|
}
|
|
318
387
|
function safeHtmlComment(message, raw) {
|
|
319
|
-
|
|
320
|
-
return `<!-- ${escapedMessage} -->
|
|
321
|
-
${raw}`;
|
|
388
|
+
return `<!-- ${message.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")} -->\n${raw}`;
|
|
322
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Heading element (h1-h6) → Title block.
|
|
392
|
+
*/
|
|
323
393
|
function convertHeading($el) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
394
|
+
const tag = tagOf($el[0]);
|
|
395
|
+
const styles = getStyles$1($el);
|
|
396
|
+
const levelMatch = tag.match(/^h(\d)$/);
|
|
397
|
+
const rawLevel = levelMatch ? Number(levelMatch[1]) : 2;
|
|
398
|
+
const level = rawLevel >= 1 && rawLevel <= 4 ? rawLevel : Math.min(rawLevel, 4);
|
|
399
|
+
const innerHtml = getInnerHtml($el);
|
|
400
|
+
return createTitleBlock({
|
|
401
|
+
content: innerHtml.trim() ? `<p>${innerHtml}</p>` : "<p></p>",
|
|
402
|
+
level,
|
|
403
|
+
color: parseColor(styles.color) || "#1a1a1a",
|
|
404
|
+
textAlign: parseAlignment(styles["text-align"]),
|
|
405
|
+
fontFamily: parseFontFamily(styles["font-family"]) || void 0,
|
|
406
|
+
styles: { padding: readPaddingFromStyles(styles) }
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Apply a container-level `text-align` to every `<p>` opening tag in `html`,
|
|
411
|
+
* merging into an existing `style="…"` attribute when present. Tolerant of
|
|
412
|
+
* any other attributes on the `<p>` (class/id/dir/…) — the previous narrow
|
|
413
|
+
* `<p style="…">` + bare-`<p>` matchers silently dropped the alignment when
|
|
414
|
+
* the inner `<p>` carried a non-style attribute.
|
|
415
|
+
*/
|
|
416
|
+
function applyTextAlignToParagraphs(html, textAlign) {
|
|
417
|
+
return html.replace(/<p\b([^>]*)>/gi, (_match, attrs) => {
|
|
418
|
+
const styleMatch = /\sstyle\s*=\s*"([^"]*)"/i.exec(attrs);
|
|
419
|
+
if (styleMatch) {
|
|
420
|
+
const existing = styleMatch[1].trim().replace(/;\s*$/, "");
|
|
421
|
+
const merged = existing ? `${existing}; text-align: ${textAlign}` : `text-align: ${textAlign}`;
|
|
422
|
+
return `<p${attrs.slice(0, styleMatch.index) + ` style="${merged}"` + attrs.slice(styleMatch.index + styleMatch[0].length)}>`;
|
|
423
|
+
}
|
|
424
|
+
return `<p${attrs} style="text-align: ${textAlign}">`;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Paragraph or block-level text container → Paragraph block.
|
|
429
|
+
*/
|
|
342
430
|
function convertParagraph($el) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
return createParagraphBlock({
|
|
371
|
-
content: result,
|
|
372
|
-
styles: {
|
|
373
|
-
padding: readPaddingFromStyles(styles)
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
}
|
|
431
|
+
const styles = getStyles$1($el);
|
|
432
|
+
const wrapped = ensureParagraphWrapped(getInnerHtml($el));
|
|
433
|
+
const fontParts = [];
|
|
434
|
+
const fontSize = parsePxValue(styles["font-size"]);
|
|
435
|
+
if (fontSize && fontSize !== 16) fontParts.push(`font-size: ${fontSize}px`);
|
|
436
|
+
const color = parseColor(styles.color);
|
|
437
|
+
if (color && color !== "#1a1a1a") fontParts.push(`color: ${color}`);
|
|
438
|
+
const fontWeight = parseFontWeight(styles["font-weight"]);
|
|
439
|
+
if (fontWeight) fontParts.push(`font-weight: ${fontWeight}`);
|
|
440
|
+
const fontFamily = parseFontFamily(styles["font-family"]);
|
|
441
|
+
if (fontFamily) fontParts.push(`font-family: ${fontFamily}`);
|
|
442
|
+
const textAlign = styles["text-align"];
|
|
443
|
+
let result = wrapped;
|
|
444
|
+
if (textAlign && textAlign !== "left") result = applyTextAlignToParagraphs(result, textAlign);
|
|
445
|
+
if (fontParts.length > 0) {
|
|
446
|
+
const span = fontParts.join("; ");
|
|
447
|
+
result = result.replace(/<p([^>]*)>([\s\S]*?)<\/p>/g, `<p$1><span style="${span}">$2</span></p>`);
|
|
448
|
+
}
|
|
449
|
+
return createParagraphBlock({
|
|
450
|
+
content: result,
|
|
451
|
+
styles: { padding: readPaddingFromStyles(styles) }
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* <img> → Image block.
|
|
456
|
+
*/
|
|
377
457
|
function convertImage($el) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
458
|
+
const styles = getStyles$1($el);
|
|
459
|
+
const src = $el.attr("src") ?? "";
|
|
460
|
+
const alt = $el.attr("alt") ?? "";
|
|
461
|
+
const widthAttr = $el.attr("width");
|
|
462
|
+
const widthStyle = styles.width;
|
|
463
|
+
return createImageBlock({
|
|
464
|
+
src,
|
|
465
|
+
alt,
|
|
466
|
+
width: parsePxValue(widthAttr) || parsePxValue(widthStyle) || 600,
|
|
467
|
+
align: parseAlignment(styles["text-align"], "center"),
|
|
468
|
+
styles: { padding: readPaddingFromStyles(styles) }
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* <a> styled as a button → Button block.
|
|
473
|
+
*
|
|
474
|
+
* Heuristic: a single `<a>` with a non-transparent background-color OR padding
|
|
475
|
+
* OR border-radius OR display: inline-block / block is treated as a button.
|
|
476
|
+
*/
|
|
394
477
|
function looksLikeButton(styles) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (display === "inline-block" || display === "block") return true;
|
|
402
|
-
return false;
|
|
478
|
+
if (parseColor(styles["background-color"]) || parseColor(styles.background)) return true;
|
|
479
|
+
if (styles.padding || styles["padding-top"] || styles["padding-bottom"] || styles["padding-left"] || styles["padding-right"]) return true;
|
|
480
|
+
if (parsePxValue(styles["border-radius"])) return true;
|
|
481
|
+
const display = (styles.display ?? "").toLowerCase();
|
|
482
|
+
if (display === "inline-block" || display === "block") return true;
|
|
483
|
+
return false;
|
|
403
484
|
}
|
|
404
485
|
function convertButton($el) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
});
|
|
423
|
-
}
|
|
486
|
+
const styles = getStyles$1($el);
|
|
487
|
+
return createButtonBlock({
|
|
488
|
+
text: ($el.text() ?? "Button").trim() || "Button",
|
|
489
|
+
url: $el.attr("href") ?? "#",
|
|
490
|
+
openInNewTab: $el.attr("target") === "_blank" || void 0,
|
|
491
|
+
backgroundColor: parseColor(styles["background-color"]) || parseColor(styles.background) || "#4f46e5",
|
|
492
|
+
textColor: parseColor(styles.color) || "#ffffff",
|
|
493
|
+
borderRadius: parsePxValue(styles["border-radius"]),
|
|
494
|
+
fontSize: parsePxValue(styles["font-size"]) || 16,
|
|
495
|
+
fontFamily: parseFontFamily(styles["font-family"]) || void 0,
|
|
496
|
+
buttonPadding: readPaddingFromStyles(styles),
|
|
497
|
+
styles: { padding: emptyPadding$2() }
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* <hr> → Divider block.
|
|
502
|
+
*/
|
|
424
503
|
function convertDivider($el) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
504
|
+
const styles = getStyles$1($el);
|
|
505
|
+
const border = parseBorderShorthand(styles["border-top"] ?? styles.border);
|
|
506
|
+
return createDividerBlock({
|
|
507
|
+
lineStyle: border.style === "dashed" || border.style === "dotted" ? border.style : "solid",
|
|
508
|
+
color: border.color || "#e5e7eb",
|
|
509
|
+
thickness: border.width || 1,
|
|
510
|
+
width: 100,
|
|
511
|
+
styles: { padding: readPaddingFromStyles(styles) }
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Wraps the element's outerHTML in an HTML block (the lossless fallback).
|
|
516
|
+
*/
|
|
438
517
|
function convertHtmlFallback($el, $, note) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
518
|
+
const outer = $.html($el) ?? "";
|
|
519
|
+
return createHtmlBlock({
|
|
520
|
+
content: note ? safeHtmlComment(note, outer) : outer,
|
|
521
|
+
styles: { padding: readPaddingFromStyles(getStyles$1($el)) }
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Decides whether a `<td>` looks like a vertical spacer:
|
|
526
|
+
* empty (or only ` `) AND has an explicit height.
|
|
527
|
+
*/
|
|
449
528
|
function isSpacerCell($el) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
529
|
+
if (($el.text() ?? "").replace(/\s| /g, "") !== "") return false;
|
|
530
|
+
if ($el.find("img, a, hr").length > 0) return false;
|
|
531
|
+
const styles = getStyles$1($el);
|
|
532
|
+
return parsePxValue($el.attr("height")) > 0 || parsePxValue(styles.height) > 0 || parsePxValue(styles["line-height"]) > 0;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Decides whether a `<td>` is a button container — i.e. has exactly one
|
|
536
|
+
* `<a>` inside that itself looks like a button.
|
|
537
|
+
*/
|
|
457
538
|
function isButtonCell($el, $) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
539
|
+
const anchors = $el.find("a");
|
|
540
|
+
if (anchors.length !== 1) return { match: false };
|
|
541
|
+
const anchor = $(anchors[0]);
|
|
542
|
+
if (looksLikeButton(getStyles$1(anchor))) return {
|
|
543
|
+
match: true,
|
|
544
|
+
anchor
|
|
545
|
+
};
|
|
546
|
+
if (looksLikeButton(getStyles$1($el))) {
|
|
547
|
+
if ((anchor.attr("href") ?? "").trim() !== "") return {
|
|
548
|
+
match: true,
|
|
549
|
+
anchor
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return { match: false };
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Converts a single content-bearing element (heading / paragraph / image /
|
|
556
|
+
* anchor-as-button / divider) to a Templatical block.
|
|
557
|
+
*
|
|
558
|
+
* Returns `null` for elements that do not contain any meaningful content
|
|
559
|
+
* (the caller should skip them).
|
|
560
|
+
*/
|
|
470
561
|
function convertElement($el, $) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
note: `Unknown element "${tag}" preserved as HTML block.`
|
|
547
|
-
}
|
|
548
|
-
};
|
|
562
|
+
const tag = tagOf($el[0]);
|
|
563
|
+
if (!tag) return null;
|
|
564
|
+
if (HEADING_TAGS.has(tag)) return {
|
|
565
|
+
block: convertHeading($el),
|
|
566
|
+
entry: {
|
|
567
|
+
sourceTag: tag,
|
|
568
|
+
templaticalBlockType: "title",
|
|
569
|
+
status: "converted"
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
if (tag === "img") return {
|
|
573
|
+
block: convertImage($el),
|
|
574
|
+
entry: {
|
|
575
|
+
sourceTag: tag,
|
|
576
|
+
templaticalBlockType: "image",
|
|
577
|
+
status: "converted"
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
if (tag === "a") {
|
|
581
|
+
if (looksLikeButton(getStyles$1($el))) return {
|
|
582
|
+
block: convertButton($el),
|
|
583
|
+
entry: {
|
|
584
|
+
sourceTag: tag,
|
|
585
|
+
templaticalBlockType: "button",
|
|
586
|
+
status: "converted"
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
return {
|
|
590
|
+
block: convertParagraph($el),
|
|
591
|
+
entry: {
|
|
592
|
+
sourceTag: tag,
|
|
593
|
+
templaticalBlockType: "paragraph",
|
|
594
|
+
status: "approximated",
|
|
595
|
+
note: "Inline anchor wrapped in a paragraph block."
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
if (tag === "hr") return {
|
|
600
|
+
block: convertDivider($el),
|
|
601
|
+
entry: {
|
|
602
|
+
sourceTag: tag,
|
|
603
|
+
templaticalBlockType: "divider",
|
|
604
|
+
status: "converted"
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
if (TEXT_TAGS.has(tag)) {
|
|
608
|
+
if (!($el.text() ?? "").trim() && $el.find("img, a").length === 0) return null;
|
|
609
|
+
return {
|
|
610
|
+
block: convertParagraph($el),
|
|
611
|
+
entry: {
|
|
612
|
+
sourceTag: tag,
|
|
613
|
+
templaticalBlockType: "paragraph",
|
|
614
|
+
status: "converted"
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
block: convertHtmlFallback($el, $, `Unsupported element <${tag}>: preserved as raw HTML`),
|
|
620
|
+
entry: {
|
|
621
|
+
sourceTag: tag,
|
|
622
|
+
templaticalBlockType: "html",
|
|
623
|
+
status: "html-fallback",
|
|
624
|
+
note: `Unknown element "${tag}" preserved as HTML block.`
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/section-builder.ts
|
|
630
|
+
function emptyPadding$1() {
|
|
631
|
+
return {
|
|
632
|
+
top: 0,
|
|
633
|
+
right: 0,
|
|
634
|
+
bottom: 0,
|
|
635
|
+
left: 0
|
|
636
|
+
};
|
|
549
637
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
import {
|
|
553
|
-
createSectionBlock,
|
|
554
|
-
createButtonBlock as createButtonBlock2,
|
|
555
|
-
createSpacerBlock as createSpacerBlock2
|
|
556
|
-
} from "@templatical/types";
|
|
557
|
-
function emptyPadding2() {
|
|
558
|
-
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
559
|
-
}
|
|
560
|
-
function getStyles2($el) {
|
|
561
|
-
return parseStyleAttribute($el.attr("style"));
|
|
638
|
+
function getStyles($el) {
|
|
639
|
+
return parseStyleAttribute($el.attr("style"));
|
|
562
640
|
}
|
|
563
641
|
function buildCellButton($cell, $anchor) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
582
|
-
});
|
|
642
|
+
const cellStyles = getStyles($cell);
|
|
643
|
+
const aStyles = getStyles($anchor);
|
|
644
|
+
const merged = {
|
|
645
|
+
...cellStyles,
|
|
646
|
+
...aStyles
|
|
647
|
+
};
|
|
648
|
+
return createButtonBlock({
|
|
649
|
+
text: ($anchor.text() ?? "Button").trim() || "Button",
|
|
650
|
+
url: $anchor.attr("href") ?? "#",
|
|
651
|
+
openInNewTab: $anchor.attr("target") === "_blank" || void 0,
|
|
652
|
+
backgroundColor: parseColor(merged["background-color"]) || parseColor(merged.background) || "#4f46e5",
|
|
653
|
+
textColor: parseColor(merged.color) || "#ffffff",
|
|
654
|
+
borderRadius: parsePxValue(merged["border-radius"]),
|
|
655
|
+
fontSize: parsePxValue(merged["font-size"]) || 16,
|
|
656
|
+
buttonPadding: readPaddingFromStyles(merged),
|
|
657
|
+
styles: { padding: emptyPadding$1() }
|
|
658
|
+
});
|
|
583
659
|
}
|
|
584
660
|
function buildSpacerFromCell($cell) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
661
|
+
const cellStyles = getStyles($cell);
|
|
662
|
+
return createSpacerBlock({
|
|
663
|
+
height: parsePxValue($cell.attr("height")) || parsePxValue(cellStyles.height) || parsePxValue(cellStyles["line-height"]) || 24,
|
|
664
|
+
styles: { padding: emptyPadding$1() }
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Returns the direct child `<tr>` rows of a table, including those one level
|
|
669
|
+
* inside `<thead>`, `<tbody>`, or `<tfoot>` (which the HTML parser inserts
|
|
670
|
+
* automatically).
|
|
671
|
+
*/
|
|
594
672
|
function getDirectRows($table, $) {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
673
|
+
const rows = [];
|
|
674
|
+
$table.children("tr").each((_, el) => {
|
|
675
|
+
rows.push($(el));
|
|
676
|
+
});
|
|
677
|
+
$table.children("thead, tbody, tfoot").each((_, group) => {
|
|
678
|
+
$(group).children("tr").each((_i, el) => {
|
|
679
|
+
rows.push($(el));
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
return rows;
|
|
605
683
|
}
|
|
606
684
|
function getDirectCells($row, $) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
685
|
+
const cells = [];
|
|
686
|
+
$row.children("td, th").each((_, el) => {
|
|
687
|
+
cells.push($(el));
|
|
688
|
+
});
|
|
689
|
+
return cells;
|
|
612
690
|
}
|
|
613
691
|
function isLayoutTable($table, $) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if ($(td).children().length > 0) hasNonStandardChild = true;
|
|
622
|
-
});
|
|
623
|
-
return hasNonStandardChild;
|
|
692
|
+
if ($table.find("img, a, h1, h2, h3, h4, h5, h6, table, hr, p, div, span, ul, ol, li, blockquote, video, iframe").length > 0) return true;
|
|
693
|
+
let hasNonStandardChild = false;
|
|
694
|
+
$table.find("td, th").each((_, td) => {
|
|
695
|
+
if (hasNonStandardChild) return;
|
|
696
|
+
if ($(td).children().length > 0) hasNonStandardChild = true;
|
|
697
|
+
});
|
|
698
|
+
return hasNonStandardChild;
|
|
624
699
|
}
|
|
625
700
|
function resolveColumnLayout(cellCount, warnings) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
);
|
|
632
|
-
return "1";
|
|
701
|
+
if (cellCount <= 1) return "1";
|
|
702
|
+
if (cellCount === 2) return "2";
|
|
703
|
+
if (cellCount === 3) return "3";
|
|
704
|
+
warnings.push(`Row with ${cellCount} columns was flattened to a single column. Templatical supports up to 3 columns per section.`);
|
|
705
|
+
return "1";
|
|
633
706
|
}
|
|
634
707
|
function extractCellBlocks($cell, $, entries, warnings) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
708
|
+
if (isSpacerCell($cell)) {
|
|
709
|
+
entries.push({
|
|
710
|
+
sourceTag: "td",
|
|
711
|
+
templaticalBlockType: "spacer",
|
|
712
|
+
status: "converted"
|
|
713
|
+
});
|
|
714
|
+
return [buildSpacerFromCell($cell)];
|
|
715
|
+
}
|
|
716
|
+
const btn = isButtonCell($cell, $);
|
|
717
|
+
if (btn.match && btn.anchor) {
|
|
718
|
+
entries.push({
|
|
719
|
+
sourceTag: "td",
|
|
720
|
+
templaticalBlockType: "button",
|
|
721
|
+
status: "converted"
|
|
722
|
+
});
|
|
723
|
+
return [buildCellButton($cell, btn.anchor)];
|
|
724
|
+
}
|
|
725
|
+
const blocks = [];
|
|
726
|
+
const childEls = $cell.children().toArray();
|
|
727
|
+
if (childEls.length === 0) {
|
|
728
|
+
if (!($cell.text() ?? "").trim()) return [];
|
|
729
|
+
const r = convertElement($cell, $);
|
|
730
|
+
if (r) {
|
|
731
|
+
entries.push(r.entry);
|
|
732
|
+
blocks.push(r.block);
|
|
733
|
+
}
|
|
734
|
+
return blocks;
|
|
735
|
+
}
|
|
736
|
+
for (const childEl of childEls) {
|
|
737
|
+
const $child = $(childEl);
|
|
738
|
+
const tag = childEl.tagName?.toLowerCase() ?? "";
|
|
739
|
+
if (tag === "table") {
|
|
740
|
+
const inner = processTable($child, $, entries, warnings, true);
|
|
741
|
+
blocks.push(...inner);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (tag === "a" && looksLikeButton(getStyles($child))) {
|
|
745
|
+
const r = convertElement($child, $);
|
|
746
|
+
if (r) {
|
|
747
|
+
entries.push(r.entry);
|
|
748
|
+
blocks.push(r.block);
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const r = convertElement($child, $);
|
|
753
|
+
if (r) {
|
|
754
|
+
entries.push(r.entry);
|
|
755
|
+
blocks.push(r.block);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return blocks;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Walk a `<table>` and produce Section blocks (one per row).
|
|
762
|
+
*
|
|
763
|
+
* @param flattenInline - When true (used for nested tables), drop the section
|
|
764
|
+
* wrapper and return the flat block list. Templatical sections cannot nest,
|
|
765
|
+
* so nested layout-tables are merged into their parent cell.
|
|
766
|
+
*/
|
|
688
767
|
function processTable($table, $, entries, warnings, flattenInline = false) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
// src/converter.ts
|
|
739
|
-
function emptyPadding3() {
|
|
740
|
-
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
768
|
+
if (!isLayoutTable($table, $)) {
|
|
769
|
+
entries.push({
|
|
770
|
+
sourceTag: "table",
|
|
771
|
+
templaticalBlockType: "html",
|
|
772
|
+
status: "html-fallback",
|
|
773
|
+
note: "Data table preserved as HTML block."
|
|
774
|
+
});
|
|
775
|
+
return [convertHtmlFallback($table, $, "Data table preserved as HTML")];
|
|
776
|
+
}
|
|
777
|
+
const rows = getDirectRows($table, $);
|
|
778
|
+
if (rows.length === 0) return [];
|
|
779
|
+
const sections = [];
|
|
780
|
+
for (const $row of rows) {
|
|
781
|
+
const cells = getDirectCells($row, $);
|
|
782
|
+
if (cells.length === 0) continue;
|
|
783
|
+
const layout = resolveColumnLayout(cells.length, warnings);
|
|
784
|
+
let columnsBlocks;
|
|
785
|
+
if (layout === "1") {
|
|
786
|
+
const merged = [];
|
|
787
|
+
for (const $cell of cells) merged.push(...extractCellBlocks($cell, $, entries, warnings));
|
|
788
|
+
columnsBlocks = [merged];
|
|
789
|
+
} else columnsBlocks = cells.map(($cell) => extractCellBlocks($cell, $, entries, warnings));
|
|
790
|
+
if (flattenInline) {
|
|
791
|
+
for (const col of columnsBlocks) sections.push(...col);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
const rowStyles = getStyles($row);
|
|
795
|
+
const bgColor = parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background);
|
|
796
|
+
const padding = readPaddingFromStyles(rowStyles);
|
|
797
|
+
sections.push(createSectionBlock({
|
|
798
|
+
columns: layout,
|
|
799
|
+
children: columnsBlocks,
|
|
800
|
+
styles: {
|
|
801
|
+
padding,
|
|
802
|
+
...bgColor ? { backgroundColor: bgColor } : {}
|
|
803
|
+
}
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
return sections;
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/converter.ts
|
|
810
|
+
function emptyPadding() {
|
|
811
|
+
return {
|
|
812
|
+
top: 0,
|
|
813
|
+
right: 0,
|
|
814
|
+
bottom: 0,
|
|
815
|
+
left: 0
|
|
816
|
+
};
|
|
741
817
|
}
|
|
742
818
|
function readPreheader($) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const text = $(candidates[0]).text().trim();
|
|
749
|
-
return text || void 0;
|
|
819
|
+
const candidates = $("body").children().slice(0, 5).filter((_, el) => {
|
|
820
|
+
return (parseStyleAttribute($(el).attr("style")).display ?? "").toLowerCase() === "none";
|
|
821
|
+
});
|
|
822
|
+
if (candidates.length === 0) return void 0;
|
|
823
|
+
return $(candidates[0]).text().trim() || void 0;
|
|
750
824
|
}
|
|
751
825
|
function extractSettings($) {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
826
|
+
const $body = $("body");
|
|
827
|
+
const bodyStyles = parseStyleAttribute($body.attr("style"));
|
|
828
|
+
const fontFamily = parseFontFamily(bodyStyles["font-family"]) || "Arial";
|
|
829
|
+
const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(bodyStyles.background) || "#ffffff";
|
|
830
|
+
const $outerTable = $body.find("table").first();
|
|
831
|
+
const widthAttr = parsePxValue($outerTable.attr("width"));
|
|
832
|
+
const widthStyle = parsePxValue(parseStyleAttribute($outerTable.attr("style")).width);
|
|
833
|
+
const width = widthAttr || widthStyle || 600;
|
|
834
|
+
const preheaderText = readPreheader($);
|
|
835
|
+
return {
|
|
836
|
+
width,
|
|
837
|
+
backgroundColor,
|
|
838
|
+
fontFamily,
|
|
839
|
+
locale: "en",
|
|
840
|
+
...preheaderText ? { preheaderText } : {}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Wrap a list of free-floating blocks (those produced by top-level non-table
|
|
845
|
+
* elements) in a single one-column section.
|
|
846
|
+
*/
|
|
771
847
|
function wrapInSection(blocks) {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
848
|
+
return createSectionBlock({
|
|
849
|
+
columns: "1",
|
|
850
|
+
children: [blocks],
|
|
851
|
+
styles: { padding: emptyPadding() }
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Walk top-level body children. Tables become sections; loose content
|
|
856
|
+
* elements are accumulated and wrapped in a single one-column section.
|
|
857
|
+
*/
|
|
780
858
|
function processBody($, entries, warnings) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
859
|
+
const blocks = [];
|
|
860
|
+
const children = $("body").children().toArray();
|
|
861
|
+
let pendingLoose = [];
|
|
862
|
+
const flushLoose = () => {
|
|
863
|
+
if (pendingLoose.length > 0) {
|
|
864
|
+
blocks.push(wrapInSection(pendingLoose));
|
|
865
|
+
pendingLoose = [];
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
for (const childEl of children) {
|
|
869
|
+
const tag = childEl.tagName?.toLowerCase() ?? "";
|
|
870
|
+
const $child = $(childEl);
|
|
871
|
+
if (tag === "table") {
|
|
872
|
+
flushLoose();
|
|
873
|
+
blocks.push(...processTable($child, $, entries, warnings, false));
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") continue;
|
|
877
|
+
if ((tag === "div" || tag === "center" || tag === "main") && $child.find("table").length > 0) {
|
|
878
|
+
flushLoose();
|
|
879
|
+
$child.children().each((_, innerEl) => {
|
|
880
|
+
const innerTag = innerEl.tagName?.toLowerCase() ?? "";
|
|
881
|
+
const $inner = $(innerEl);
|
|
882
|
+
if (innerTag === "table") {
|
|
883
|
+
flushLoose();
|
|
884
|
+
blocks.push(...processTable($inner, $, entries, warnings, false));
|
|
885
|
+
} else {
|
|
886
|
+
const r = convertElement($inner, $);
|
|
887
|
+
if (r) {
|
|
888
|
+
entries.push(r.entry);
|
|
889
|
+
pendingLoose.push(r.block);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
flushLoose();
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const r = convertElement($child, $);
|
|
897
|
+
if (r) {
|
|
898
|
+
entries.push(r.entry);
|
|
899
|
+
pendingLoose.push(r.block);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
flushLoose();
|
|
903
|
+
return blocks;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Converts an HTML email template to Templatical TemplateContent.
|
|
907
|
+
*
|
|
908
|
+
* Designed for table-based marketing email HTML (output of MJML, Mailchimp,
|
|
909
|
+
* SendGrid, Campaign Monitor, hand-coded emails). Modern HTML using flex/grid
|
|
910
|
+
* layouts is preserved via HTML-fallback blocks.
|
|
911
|
+
*
|
|
912
|
+
* @param html - The raw HTML string (full document or body fragment).
|
|
913
|
+
* @returns An ImportResult with the converted content and a detailed report.
|
|
914
|
+
*
|
|
915
|
+
* @example
|
|
916
|
+
* ```ts
|
|
917
|
+
* import { convertHtmlTemplate } from '@templatical/import-html';
|
|
918
|
+
*
|
|
919
|
+
* const html = await fetch('/email.html').then((r) => r.text());
|
|
920
|
+
* const { content, report } = convertHtmlTemplate(html);
|
|
921
|
+
*
|
|
922
|
+
* const editor = init({ container: '#editor', content });
|
|
923
|
+
*
|
|
924
|
+
* console.log(report.summary);
|
|
925
|
+
* console.log(report.warnings);
|
|
926
|
+
* ```
|
|
927
|
+
*/
|
|
828
928
|
function convertHtmlTemplate(html) {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
skipped: entries.filter((e) => e.status === "skipped").length
|
|
861
|
-
};
|
|
862
|
-
const report = { entries, warnings, summary };
|
|
863
|
-
return { content, report };
|
|
864
|
-
}
|
|
865
|
-
export {
|
|
866
|
-
convertHtmlTemplate
|
|
867
|
-
};
|
|
929
|
+
if (typeof html !== "string") throw new Error("Invalid HTML template: expected a string. Pass the raw HTML source as a string.");
|
|
930
|
+
if (html.trim().length === 0) throw new Error("Invalid HTML template: input is empty. Pass the raw HTML source of an email.");
|
|
931
|
+
const $ = load(html);
|
|
932
|
+
resolveCssStyles($);
|
|
933
|
+
$("script, noscript, link, meta, title").remove();
|
|
934
|
+
const entries = [];
|
|
935
|
+
const warnings = [];
|
|
936
|
+
const blocks = processBody($, entries, warnings);
|
|
937
|
+
if (blocks.length === 0) warnings.push("No convertible content was found in the HTML. The email may use a non-table layout — modern HTML support is limited.");
|
|
938
|
+
return {
|
|
939
|
+
content: {
|
|
940
|
+
...createDefaultTemplateContent(),
|
|
941
|
+
blocks,
|
|
942
|
+
settings: extractSettings($)
|
|
943
|
+
},
|
|
944
|
+
report: {
|
|
945
|
+
entries,
|
|
946
|
+
warnings,
|
|
947
|
+
summary: {
|
|
948
|
+
total: entries.length,
|
|
949
|
+
converted: entries.filter((e) => e.status === "converted").length,
|
|
950
|
+
approximated: entries.filter((e) => e.status === "approximated").length,
|
|
951
|
+
htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
|
|
952
|
+
skipped: entries.filter((e) => e.status === "skipped").length
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
958
|
+
export { convertHtmlTemplate };
|
|
959
|
+
|
|
868
960
|
//# sourceMappingURL=index.js.map
|