@templatical/import-html 0.10.0 → 0.10.2

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,868 +1,960 @@
1
- // src/converter.ts
2
1
  import { load } from "cheerio";
3
- import {
4
- createDefaultTemplateContent,
5
- createSectionBlock as createSectionBlock2
6
- } from "@templatical/types";
7
-
8
- // src/style-parser.ts
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
- const result = {};
11
- if (!styleAttr) return result;
12
- for (const decl of styleAttr.split(";")) {
13
- const idx = decl.indexOf(":");
14
- if (idx === -1) continue;
15
- const key = decl.slice(0, idx).trim().toLowerCase();
16
- const value = decl.slice(idx + 1).trim();
17
- if (key && value) result[key] = value;
18
- }
19
- return result;
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
- return Object.entries(styles).map(([k, v]) => `${k}: ${v}`).join("; ");
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
- if (value === void 0 || value === null || value === "") return 0;
26
- if (typeof value === "number") return Math.round(value);
27
- const match = value.match(/^(-?\d+(?:\.\d+)?)\s*(?:px)?\s*$/);
28
- return match ? Math.round(parseFloat(match[1])) : 0;
29
- }
30
- var NAMED_COLORS = {
31
- black: "#000000",
32
- white: "#ffffff",
33
- red: "#ff0000",
34
- green: "#008000",
35
- blue: "#0000ff",
36
- yellow: "#ffff00",
37
- cyan: "#00ffff",
38
- magenta: "#ff00ff",
39
- gray: "#808080",
40
- grey: "#808080",
41
- silver: "#c0c0c0",
42
- maroon: "#800000",
43
- olive: "#808000",
44
- lime: "#00ff00",
45
- aqua: "#00ffff",
46
- teal: "#008080",
47
- navy: "#000080",
48
- fuchsia: "#ff00ff",
49
- purple: "#800080",
50
- orange: "#ffa500",
51
- pink: "#ffc0cb"
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
- const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
55
- const hex = (n) => clamp(n).toString(16).padStart(2, "0");
56
- return `#${hex(r)}${hex(g)}${hex(b)}`;
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
- if (!value) return "";
60
- const trimmed = value.trim().toLowerCase();
61
- if (trimmed === "transparent" || trimmed === "inherit" || trimmed === "none")
62
- return "";
63
- if (/^#[0-9a-f]{6}$/.test(trimmed)) return trimmed;
64
- if (/^#[0-9a-f]{3}$/.test(trimmed)) {
65
- const r = trimmed[1];
66
- const g = trimmed[2];
67
- const b = trimmed[3];
68
- return `#${r}${r}${g}${g}${b}${b}`;
69
- }
70
- const rgbMatch = trimmed.match(
71
- /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/
72
- );
73
- if (rgbMatch) {
74
- return rgbToHex(
75
- parseInt(rgbMatch[1], 10),
76
- parseInt(rgbMatch[2], 10),
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
- if (!value) return { top: 0, right: 0, bottom: 0, left: 0 };
85
- const parts = value.trim().split(/\s+/);
86
- const values = parts.map((p) => parsePxValue(p));
87
- switch (values.length) {
88
- case 1:
89
- return {
90
- top: values[0],
91
- right: values[0],
92
- bottom: values[0],
93
- left: values[0]
94
- };
95
- case 2:
96
- return {
97
- top: values[0],
98
- right: values[1],
99
- bottom: values[0],
100
- left: values[1]
101
- };
102
- case 3:
103
- return {
104
- top: values[0],
105
- right: values[1],
106
- bottom: values[2],
107
- left: values[1]
108
- };
109
- default:
110
- return {
111
- top: values[0],
112
- right: values[1],
113
- bottom: values[2],
114
- left: values[3]
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
- const shorthand = parsePaddingShorthand(styles.padding);
120
- return {
121
- top: parsePxValue(styles["padding-top"]) || shorthand.top,
122
- right: parsePxValue(styles["padding-right"]) || shorthand.right,
123
- bottom: parsePxValue(styles["padding-bottom"]) || shorthand.bottom,
124
- left: parsePxValue(styles["padding-left"]) || shorthand.left
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
- if (!value) return "";
129
- return value.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
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
- if (!value) return "";
133
- const trimmed = value.trim().toLowerCase();
134
- if (trimmed === "normal" || trimmed === "400") return "";
135
- return trimmed;
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
- const v = (value ?? "").trim().toLowerCase();
139
- if (v === "left" || v === "center" || v === "right") return v;
140
- return fallback;
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
- const fallback = { width: 0, style: "solid", color: "#000000" };
144
- if (!value) return fallback;
145
- const styleKeywords = /* @__PURE__ */ new Set([
146
- "none",
147
- "hidden",
148
- "dotted",
149
- "dashed",
150
- "solid",
151
- "double",
152
- "groove",
153
- "ridge",
154
- "inset",
155
- "outset"
156
- ]);
157
- let width = 0;
158
- let style = "solid";
159
- let color = "#000000";
160
- for (const token of value.trim().split(/\s+/)) {
161
- const lower = token.toLowerCase();
162
- if (styleKeywords.has(lower)) {
163
- style = lower;
164
- } else if (/^-?\d+(?:\.\d+)?(?:px)?$/i.test(lower)) {
165
- width = parsePxValue(lower);
166
- } else {
167
- const c = parseColor(lower);
168
- if (c) color = c;
169
- }
170
- }
171
- return { width, style, color };
172
- }
173
-
174
- // src/css-resolver.ts
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
- return css.replace(/\/\*[\s\S]*?\*\//g, "");
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
- let result = "";
180
- let i = 0;
181
- while (i < css.length) {
182
- if (css[i] === "@") {
183
- const semiIdx = css.indexOf(";", i);
184
- const braceIdx = css.indexOf("{", i);
185
- if (braceIdx === -1 || semiIdx !== -1 && semiIdx < braceIdx) {
186
- i = semiIdx === -1 ? css.length : semiIdx + 1;
187
- continue;
188
- }
189
- let depth = 0;
190
- let j = braceIdx;
191
- for (; j < css.length; j++) {
192
- if (css[j] === "{") depth++;
193
- else if (css[j] === "}") {
194
- depth--;
195
- if (depth === 0) {
196
- j++;
197
- break;
198
- }
199
- }
200
- }
201
- i = j;
202
- } else {
203
- result += css[i];
204
- i++;
205
- }
206
- }
207
- return result;
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
- const result = {};
211
- for (const decl of text.split(";")) {
212
- const idx = decl.indexOf(":");
213
- if (idx === -1) continue;
214
- const key = decl.slice(0, idx).trim().toLowerCase();
215
- let value = decl.slice(idx + 1).trim();
216
- value = value.replace(/!important\s*$/i, "").trim();
217
- if (key && value) result[key] = value;
218
- }
219
- return result;
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
- if (!selector) return false;
223
- if (selector.includes(":")) return false;
224
- if (selector.includes("@")) return false;
225
- return true;
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
- const rules = [];
229
- const cleaned = stripAtRules(stripComments(css));
230
- const blockRe = /([^{}]+)\{([^{}]*)\}/g;
231
- let match;
232
- while ((match = blockRe.exec(cleaned)) !== null) {
233
- const selectorPart = match[1].trim();
234
- const declarationPart = match[2];
235
- if (!selectorPart) continue;
236
- const selectors = selectorPart.split(",").map((s) => s.trim()).filter(isSupportedSelector);
237
- if (selectors.length === 0) continue;
238
- const declarations = parseDeclarations(declarationPart);
239
- if (Object.keys(declarations).length === 0) continue;
240
- rules.push({ selectors, declarations });
241
- }
242
- return rules;
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
- const styleTags = $("style");
246
- if (styleTags.length === 0) return;
247
- const allRules = [];
248
- styleTags.each((_, el) => {
249
- const css = $(el).text();
250
- if (css) allRules.push(...parseStyleSheet(css));
251
- });
252
- const inlineByEl = /* @__PURE__ */ new WeakMap();
253
- $("[style]").each((_, el) => {
254
- inlineByEl.set(el, parseStyleAttribute($(el).attr("style")));
255
- });
256
- const resolvedByEl = /* @__PURE__ */ new WeakMap();
257
- for (const rule of allRules) {
258
- for (const selector of rule.selectors) {
259
- let matched;
260
- try {
261
- matched = $(selector);
262
- } catch {
263
- continue;
264
- }
265
- matched.each((_, el) => {
266
- const key = el;
267
- const current = resolvedByEl.get(key) ?? {};
268
- for (const [k, v] of Object.entries(rule.declarations)) {
269
- current[k] = v;
270
- }
271
- resolvedByEl.set(key, current);
272
- });
273
- }
274
- }
275
- $("*").each((_, el) => {
276
- const key = el;
277
- const resolved = resolvedByEl.get(key);
278
- if (!resolved) return;
279
- const inline = inlineByEl.get(key) ?? {};
280
- const merged = { ...resolved };
281
- for (const [k, v] of Object.entries(inline)) merged[k] = v;
282
- $(el).attr("style", serializeStyleAttribute(merged));
283
- });
284
- styleTags.remove();
285
- }
286
-
287
- // src/block-mapper.ts
288
- import {
289
- createTitleBlock,
290
- createParagraphBlock,
291
- createImageBlock,
292
- createButtonBlock,
293
- createDividerBlock,
294
- createSpacerBlock,
295
- createHtmlBlock
296
- } from "@templatical/types";
297
- var HEADING_TAGS = /* @__PURE__ */ new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
298
- var TEXT_TAGS = /* @__PURE__ */ new Set(["p", "span", "div"]);
299
- function emptyPadding() {
300
- return { top: 0, right: 0, bottom: 0, left: 0 };
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
- if ("tagName" in el && typeof el.tagName === "string")
304
- return el.tagName.toLowerCase();
305
- return "";
370
+ if ("tagName" in el && typeof el.tagName === "string") return el.tagName.toLowerCase();
371
+ return "";
306
372
  }
307
- function getStyles($el) {
308
- return parseStyleAttribute($el.attr("style"));
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
- return $el.html() ?? "";
380
+ return $el.html() ?? "";
312
381
  }
313
382
  function ensureParagraphWrapped(html) {
314
- if (!html.trim()) return "<p></p>";
315
- if (/<(p|h[1-6]|ul|ol|blockquote)[\s>]/i.test(html)) return html;
316
- return `<p>${html}</p>`;
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
- const escapedMessage = message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
320
- return `<!-- ${escapedMessage} -->
321
- ${raw}`;
388
+ return `<!-- ${message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")} -->\n${raw}`;
322
389
  }
390
+ /**
391
+ * Heading element (h1-h6) → Title block.
392
+ */
323
393
  function convertHeading($el) {
324
- const tag = tagOf($el[0]);
325
- const styles = getStyles($el);
326
- const levelMatch = tag.match(/^h(\d)$/);
327
- const rawLevel = levelMatch ? Number(levelMatch[1]) : 2;
328
- const level = rawLevel >= 1 && rawLevel <= 4 ? rawLevel : Math.min(rawLevel, 4);
329
- const innerHtml = getInnerHtml($el);
330
- const content = innerHtml.trim() ? `<p>${innerHtml}</p>` : "<p></p>";
331
- return createTitleBlock({
332
- content,
333
- level,
334
- color: parseColor(styles.color) || "#1a1a1a",
335
- textAlign: parseAlignment(styles["text-align"]),
336
- fontFamily: parseFontFamily(styles["font-family"]) || void 0,
337
- styles: {
338
- padding: readPaddingFromStyles(styles)
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
- const styles = getStyles($el);
344
- const innerHtml = getInnerHtml($el);
345
- const wrapped = ensureParagraphWrapped(innerHtml);
346
- const fontParts = [];
347
- const fontSize = parsePxValue(styles["font-size"]);
348
- if (fontSize && fontSize !== 16) fontParts.push(`font-size: ${fontSize}px`);
349
- const color = parseColor(styles.color);
350
- if (color && color !== "#1a1a1a") fontParts.push(`color: ${color}`);
351
- const fontWeight = parseFontWeight(styles["font-weight"]);
352
- if (fontWeight) fontParts.push(`font-weight: ${fontWeight}`);
353
- const fontFamily = parseFontFamily(styles["font-family"]);
354
- if (fontFamily) fontParts.push(`font-family: ${fontFamily}`);
355
- const textAlign = styles["text-align"];
356
- let result = wrapped;
357
- if (textAlign && textAlign !== "left") {
358
- result = result.replace(
359
- /<p style="([^"]*)">/g,
360
- `<p style="$1; text-align: ${textAlign}">`
361
- ).replaceAll("<p>", `<p style="text-align: ${textAlign}">`);
362
- }
363
- if (fontParts.length > 0) {
364
- const span = fontParts.join("; ");
365
- result = result.replace(
366
- /<p([^>]*)>([\s\S]*?)<\/p>/g,
367
- `<p$1><span style="${span}">$2</span></p>`
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
- const styles = getStyles($el);
379
- const src = $el.attr("src") ?? "";
380
- const alt = $el.attr("alt") ?? "";
381
- const widthAttr = $el.attr("width");
382
- const widthStyle = styles.width;
383
- const width = parsePxValue(widthAttr) || parsePxValue(widthStyle) || 600;
384
- return createImageBlock({
385
- src,
386
- alt,
387
- width,
388
- align: parseAlignment(styles["text-align"], "center"),
389
- styles: {
390
- padding: readPaddingFromStyles(styles)
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
- if (parseColor(styles["background-color"]) || parseColor(styles.background))
396
- return true;
397
- if (styles.padding || styles["padding-top"] || styles["padding-bottom"] || styles["padding-left"] || styles["padding-right"])
398
- return true;
399
- if (parsePxValue(styles["border-radius"])) return true;
400
- const display = (styles.display ?? "").toLowerCase();
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
- const styles = getStyles($el);
406
- const text = ($el.text() ?? "Button").trim() || "Button";
407
- const url = $el.attr("href") ?? "#";
408
- const target = $el.attr("target");
409
- return createButtonBlock({
410
- text,
411
- url,
412
- openInNewTab: target === "_blank" || void 0,
413
- backgroundColor: parseColor(styles["background-color"]) || parseColor(styles.background) || "#4f46e5",
414
- textColor: parseColor(styles.color) || "#ffffff",
415
- borderRadius: parsePxValue(styles["border-radius"]),
416
- fontSize: parsePxValue(styles["font-size"]) || 16,
417
- fontFamily: parseFontFamily(styles["font-family"]) || void 0,
418
- buttonPadding: readPaddingFromStyles(styles),
419
- styles: {
420
- padding: emptyPadding()
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
- const styles = getStyles($el);
426
- const border = parseBorderShorthand(styles["border-top"] ?? styles.border);
427
- const lineStyle = border.style === "dashed" || border.style === "dotted" ? border.style : "solid";
428
- return createDividerBlock({
429
- lineStyle,
430
- color: border.color || "#e5e7eb",
431
- thickness: border.width || 1,
432
- width: 100,
433
- styles: {
434
- padding: readPaddingFromStyles(styles)
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
- const outer = $.html($el) ?? "";
440
- const content = note ? safeHtmlComment(note, outer) : outer;
441
- const styles = getStyles($el);
442
- return createHtmlBlock({
443
- content,
444
- styles: {
445
- padding: readPaddingFromStyles(styles)
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 `&nbsp;`) AND has an explicit height.
527
+ */
449
528
  function isSpacerCell($el) {
450
- const text = ($el.text() ?? "").replace(/\s| /g, "");
451
- if (text !== "") return false;
452
- if ($el.find("img, a, hr").length > 0) return false;
453
- const styles = getStyles($el);
454
- const hasHeight = parsePxValue($el.attr("height")) > 0 || parsePxValue(styles.height) > 0 || parsePxValue(styles["line-height"]) > 0;
455
- return hasHeight;
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
- const anchors = $el.find("a");
459
- if (anchors.length !== 1) return { match: false };
460
- const anchor = $(anchors[0]);
461
- if (looksLikeButton(getStyles(anchor))) return { match: true, anchor };
462
- if (looksLikeButton(getStyles($el))) {
463
- const href = (anchor.attr("href") ?? "").trim();
464
- if (href !== "") {
465
- return { match: true, anchor };
466
- }
467
- }
468
- return { match: false };
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
- const tag = tagOf($el[0]);
472
- if (!tag) return null;
473
- if (HEADING_TAGS.has(tag)) {
474
- return {
475
- block: convertHeading($el),
476
- entry: {
477
- sourceTag: tag,
478
- templaticalBlockType: "title",
479
- status: "converted"
480
- }
481
- };
482
- }
483
- if (tag === "img") {
484
- return {
485
- block: convertImage($el),
486
- entry: {
487
- sourceTag: tag,
488
- templaticalBlockType: "image",
489
- status: "converted"
490
- }
491
- };
492
- }
493
- if (tag === "a") {
494
- if (looksLikeButton(getStyles($el))) {
495
- return {
496
- block: convertButton($el),
497
- entry: {
498
- sourceTag: tag,
499
- templaticalBlockType: "button",
500
- status: "converted"
501
- }
502
- };
503
- }
504
- return {
505
- block: convertParagraph($el),
506
- entry: {
507
- sourceTag: tag,
508
- templaticalBlockType: "paragraph",
509
- status: "approximated",
510
- note: "Inline anchor wrapped in a paragraph block."
511
- }
512
- };
513
- }
514
- if (tag === "hr") {
515
- return {
516
- block: convertDivider($el),
517
- entry: {
518
- sourceTag: tag,
519
- templaticalBlockType: "divider",
520
- status: "converted"
521
- }
522
- };
523
- }
524
- if (TEXT_TAGS.has(tag)) {
525
- const text = ($el.text() ?? "").trim();
526
- if (!text && $el.find("img, a").length === 0) return null;
527
- return {
528
- block: convertParagraph($el),
529
- entry: {
530
- sourceTag: tag,
531
- templaticalBlockType: "paragraph",
532
- status: "converted"
533
- }
534
- };
535
- }
536
- return {
537
- block: convertHtmlFallback(
538
- $el,
539
- $,
540
- `Unsupported element <${tag}>: preserved as raw HTML`
541
- ),
542
- entry: {
543
- sourceTag: tag,
544
- templaticalBlockType: "html",
545
- status: "html-fallback",
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
- // src/section-builder.ts
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
- const cellStyles = getStyles2($cell);
565
- const aStyles = getStyles2($anchor);
566
- const merged = { ...cellStyles, ...aStyles };
567
- const text = ($anchor.text() ?? "Button").trim() || "Button";
568
- const url = $anchor.attr("href") ?? "#";
569
- const target = $anchor.attr("target");
570
- return createButtonBlock2({
571
- text,
572
- url,
573
- openInNewTab: target === "_blank" || void 0,
574
- backgroundColor: parseColor(merged["background-color"]) || parseColor(merged.background) || "#4f46e5",
575
- textColor: parseColor(merged.color) || "#ffffff",
576
- borderRadius: parsePxValue(merged["border-radius"]),
577
- fontSize: parsePxValue(merged["font-size"]) || 16,
578
- buttonPadding: readPaddingFromStyles(merged),
579
- styles: {
580
- padding: emptyPadding2()
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
- const cellStyles = getStyles2($cell);
586
- const height = parsePxValue($cell.attr("height")) || parsePxValue(cellStyles.height) || parsePxValue(cellStyles["line-height"]) || 24;
587
- return createSpacerBlock2({
588
- height,
589
- styles: {
590
- padding: emptyPadding2()
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
- const rows = [];
596
- $table.children("tr").each((_, el) => {
597
- rows.push($(el));
598
- });
599
- $table.children("thead, tbody, tfoot").each((_, group) => {
600
- $(group).children("tr").each((_i, el) => {
601
- rows.push($(el));
602
- });
603
- });
604
- return rows;
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
- const cells = [];
608
- $row.children("td, th").each((_, el) => {
609
- cells.push($(el));
610
- });
611
- return cells;
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
- if ($table.find(
615
- "img, a, h1, h2, h3, h4, h5, h6, table, hr, p, div, span, ul, ol, li, blockquote, video, iframe"
616
- ).length > 0)
617
- return true;
618
- let hasNonStandardChild = false;
619
- $table.find("td, th").each((_, td) => {
620
- if (hasNonStandardChild) return;
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
- if (cellCount <= 1) return "1";
627
- if (cellCount === 2) return "2";
628
- if (cellCount === 3) return "3";
629
- warnings.push(
630
- `Row with ${cellCount} columns was flattened to a single column. Templatical supports up to 3 columns per section.`
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
- if (isSpacerCell($cell)) {
636
- entries.push({
637
- sourceTag: "td",
638
- templaticalBlockType: "spacer",
639
- status: "converted"
640
- });
641
- return [buildSpacerFromCell($cell)];
642
- }
643
- const btn = isButtonCell($cell, $);
644
- if (btn.match && btn.anchor) {
645
- entries.push({
646
- sourceTag: "td",
647
- templaticalBlockType: "button",
648
- status: "converted"
649
- });
650
- return [buildCellButton($cell, btn.anchor)];
651
- }
652
- const blocks = [];
653
- const childEls = $cell.children().toArray();
654
- if (childEls.length === 0) {
655
- const text = ($cell.text() ?? "").trim();
656
- if (!text) return [];
657
- const r = convertElement($cell, $);
658
- if (r) {
659
- entries.push(r.entry);
660
- blocks.push(r.block);
661
- }
662
- return blocks;
663
- }
664
- for (const childEl of childEls) {
665
- const $child = $(childEl);
666
- const tag = childEl.tagName?.toLowerCase() ?? "";
667
- if (tag === "table") {
668
- const inner = processTable($child, $, entries, warnings, true);
669
- blocks.push(...inner);
670
- continue;
671
- }
672
- if (tag === "a" && looksLikeButton(getStyles2($child))) {
673
- const r2 = convertElement($child, $);
674
- if (r2) {
675
- entries.push(r2.entry);
676
- blocks.push(r2.block);
677
- }
678
- continue;
679
- }
680
- const r = convertElement($child, $);
681
- if (r) {
682
- entries.push(r.entry);
683
- blocks.push(r.block);
684
- }
685
- }
686
- return blocks;
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
- if (!isLayoutTable($table, $)) {
690
- entries.push({
691
- sourceTag: "table",
692
- templaticalBlockType: "html",
693
- status: "html-fallback",
694
- note: "Data table preserved as HTML block."
695
- });
696
- return [convertHtmlFallback($table, $, "Data table preserved as HTML")];
697
- }
698
- const rows = getDirectRows($table, $);
699
- if (rows.length === 0) return [];
700
- const sections = [];
701
- for (const $row of rows) {
702
- const cells = getDirectCells($row, $);
703
- if (cells.length === 0) continue;
704
- const layout = resolveColumnLayout(cells.length, warnings);
705
- let columnsBlocks;
706
- if (layout === "1") {
707
- const merged = [];
708
- for (const $cell of cells) {
709
- merged.push(...extractCellBlocks($cell, $, entries, warnings));
710
- }
711
- columnsBlocks = [merged];
712
- } else {
713
- columnsBlocks = cells.map(
714
- ($cell) => extractCellBlocks($cell, $, entries, warnings)
715
- );
716
- }
717
- if (flattenInline) {
718
- for (const col of columnsBlocks) sections.push(...col);
719
- continue;
720
- }
721
- const rowStyles = getStyles2($row);
722
- const bgColor = parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background);
723
- const padding = readPaddingFromStyles(rowStyles);
724
- sections.push(
725
- createSectionBlock({
726
- columns: layout,
727
- children: columnsBlocks,
728
- styles: {
729
- padding,
730
- ...bgColor ? { backgroundColor: bgColor } : {}
731
- }
732
- })
733
- );
734
- }
735
- return sections;
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
- const candidates = $("body").children().slice(0, 5).filter((_, el) => {
744
- const styles = parseStyleAttribute($(el).attr("style"));
745
- return (styles.display ?? "").toLowerCase() === "none";
746
- });
747
- if (candidates.length === 0) return void 0;
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
- const $body = $("body");
753
- const bodyStyles = parseStyleAttribute($body.attr("style"));
754
- const fontFamily = parseFontFamily(bodyStyles["font-family"]) || "Arial";
755
- const backgroundColor = parseColor(bodyStyles["background-color"]) || parseColor(bodyStyles.background) || "#ffffff";
756
- const $outerTable = $body.find("table").first();
757
- const widthAttr = parsePxValue($outerTable.attr("width"));
758
- const widthStyle = parsePxValue(
759
- parseStyleAttribute($outerTable.attr("style")).width
760
- );
761
- const width = widthAttr || widthStyle || 600;
762
- const preheaderText = readPreheader($);
763
- return {
764
- width,
765
- backgroundColor,
766
- fontFamily,
767
- locale: "en",
768
- ...preheaderText ? { preheaderText } : {}
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
- return createSectionBlock2({
773
- columns: "1",
774
- children: [blocks],
775
- styles: {
776
- padding: emptyPadding3()
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
- const blocks = [];
782
- const $body = $("body");
783
- const children = $body.children().toArray();
784
- let pendingLoose = [];
785
- const flushLoose = () => {
786
- if (pendingLoose.length > 0) {
787
- blocks.push(wrapInSection(pendingLoose));
788
- pendingLoose = [];
789
- }
790
- };
791
- for (const childEl of children) {
792
- const tag = childEl.tagName?.toLowerCase() ?? "";
793
- const $child = $(childEl);
794
- if (tag === "table") {
795
- flushLoose();
796
- blocks.push(...processTable($child, $, entries, warnings, false));
797
- continue;
798
- }
799
- const childStyles = parseStyleAttribute($child.attr("style"));
800
- if ((childStyles.display ?? "").toLowerCase() === "none") continue;
801
- if ((tag === "div" || tag === "center" || tag === "main") && $child.find("table").length > 0) {
802
- flushLoose();
803
- $child.children().each((_, innerEl) => {
804
- const innerTag = innerEl.tagName?.toLowerCase() ?? "";
805
- const $inner = $(innerEl);
806
- if (innerTag === "table") {
807
- blocks.push(...processTable($inner, $, entries, warnings, false));
808
- } else {
809
- const r2 = convertElement($inner, $);
810
- if (r2) {
811
- entries.push(r2.entry);
812
- pendingLoose.push(r2.block);
813
- }
814
- }
815
- });
816
- flushLoose();
817
- continue;
818
- }
819
- const r = convertElement($child, $);
820
- if (r) {
821
- entries.push(r.entry);
822
- pendingLoose.push(r.block);
823
- }
824
- }
825
- flushLoose();
826
- return blocks;
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
- if (typeof html !== "string") {
830
- throw new Error(
831
- "Invalid HTML template: expected a string. Pass the raw HTML source as a string."
832
- );
833
- }
834
- if (html.trim().length === 0) {
835
- throw new Error(
836
- "Invalid HTML template: input is empty. Pass the raw HTML source of an email."
837
- );
838
- }
839
- const $ = load(html);
840
- resolveCssStyles($);
841
- $("script, noscript, link, meta, title").remove();
842
- const entries = [];
843
- const warnings = [];
844
- const blocks = processBody($, entries, warnings);
845
- if (blocks.length === 0) {
846
- warnings.push(
847
- "No convertible content was found in the HTML. The email may use a non-table layout \u2014 modern HTML support is limited."
848
- );
849
- }
850
- const content = {
851
- ...createDefaultTemplateContent(),
852
- blocks,
853
- settings: extractSettings($)
854
- };
855
- const summary = {
856
- total: entries.length,
857
- converted: entries.filter((e) => e.status === "converted").length,
858
- approximated: entries.filter((e) => e.status === "approximated").length,
859
- htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
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