@templatical/import-beefree 0.52.1 → 0.54.0
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 +63 -4
- package/dist/index.js +544 -47
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
|
-
import { canonicalFontStack, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, createVideoBlock, generateId } from "@templatical/types";
|
|
1
|
+
import { canonicalFontStack, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, createVideoBlock, generateId, isLocaleTag, isSafeHtmlUrl, sourceHash } from "@templatical/types";
|
|
2
|
+
// src/localization.ts
|
|
3
|
+
function warn(warnings, message) {
|
|
4
|
+
if (!warnings.includes(message)) warnings.push(message);
|
|
5
|
+
}
|
|
6
|
+
function localeTag(value, warnings) {
|
|
7
|
+
const locale = value.replaceAll("_", "-");
|
|
8
|
+
if (isLocaleTag(locale)) return locale;
|
|
9
|
+
warn(warnings, `Dropped language "${value}": unsupported language tag.`);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
/** The `defaultLocale` option as a tag; `null`, with a warning, when it isn't one. */
|
|
13
|
+
function defaultLocaleOption(value, warnings) {
|
|
14
|
+
if (!value) return null;
|
|
15
|
+
const locale = value.replaceAll("_", "-");
|
|
16
|
+
if (isLocaleTag(locale)) return locale;
|
|
17
|
+
warn(warnings, `Ignored defaultLocale "${value}": unsupported language tag. Used "en".`);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function text(target, locale, path, base, value) {
|
|
21
|
+
if (base === void 0) {
|
|
22
|
+
set(target, locale, path, value);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const layer = (target.l10n ??= {})[locale] ??= {};
|
|
26
|
+
(layer.text ??= {})[path] = {
|
|
27
|
+
value,
|
|
28
|
+
sourceHash: sourceHash(base)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function set(target, locale, path, value) {
|
|
32
|
+
const layer = (target.l10n ??= {})[locale] ??= {};
|
|
33
|
+
(layer.set ??= {})[path] = value;
|
|
34
|
+
}
|
|
35
|
+
/** Register only non-default versions, leaving single-language imports unchanged. */
|
|
36
|
+
function registerLocales(content) {
|
|
37
|
+
const locales = /* @__PURE__ */ new Set();
|
|
38
|
+
const visit = (target) => {
|
|
39
|
+
if (!target.l10n) return;
|
|
40
|
+
delete target.l10n[content.settings.locale];
|
|
41
|
+
for (const locale of Object.keys(target.l10n)) locales.add(locale);
|
|
42
|
+
if (Object.keys(target.l10n).length === 0) delete target.l10n;
|
|
43
|
+
};
|
|
44
|
+
visit(content.settings);
|
|
45
|
+
const walk = (blocks) => {
|
|
46
|
+
for (const block of blocks) {
|
|
47
|
+
visit(block);
|
|
48
|
+
if (block.type === "section") block.children.forEach(walk);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
walk(content.blocks);
|
|
52
|
+
if (locales.size) content.settings.locales = [...locales];
|
|
53
|
+
}
|
|
2
54
|
// src/style-parser.ts
|
|
3
55
|
/**
|
|
4
56
|
* Parses CSS-like style values from BeeFree descriptors.
|
|
@@ -69,17 +121,88 @@ function parseColor(value) {
|
|
|
69
121
|
}
|
|
70
122
|
return trimmed;
|
|
71
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether a CSS colour paints nothing: `transparent`, or an alpha of 0 in a
|
|
126
|
+
* hex (`#rgba`, `#rrggbbaa`) or functional (`rgba()`, `hsl(… / 0%)`) colour.
|
|
127
|
+
*/
|
|
128
|
+
function isTransparentColor(value) {
|
|
129
|
+
if (!value) return false;
|
|
130
|
+
const color = value.trim().toLowerCase();
|
|
131
|
+
if (color === "transparent") return true;
|
|
132
|
+
const hex = color.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/);
|
|
133
|
+
if (hex) return /^0+$/.test(hex[1] ?? hex[2]);
|
|
134
|
+
const fn = color.match(/^(?:rgb|hsl)a?\((.*)\)$/);
|
|
135
|
+
if (!fn) return false;
|
|
136
|
+
const alpha = fn[1].includes("/") ? fn[1].split("/")[1] : fn[1].split(",")[3];
|
|
137
|
+
if (alpha === void 0) return false;
|
|
138
|
+
const opacity = parseFloat(alpha);
|
|
139
|
+
return Number.isFinite(opacity) && opacity <= 0;
|
|
140
|
+
}
|
|
141
|
+
const BORDER_STYLES = /* @__PURE__ */ new Set([
|
|
142
|
+
"none",
|
|
143
|
+
"hidden",
|
|
144
|
+
"dotted",
|
|
145
|
+
"dashed",
|
|
146
|
+
"solid",
|
|
147
|
+
"double",
|
|
148
|
+
"groove",
|
|
149
|
+
"ridge",
|
|
150
|
+
"inset",
|
|
151
|
+
"outset"
|
|
152
|
+
]);
|
|
153
|
+
const BORDER_WIDTH_KEYWORDS = {
|
|
154
|
+
thin: 1,
|
|
155
|
+
medium: 3,
|
|
156
|
+
thick: 5
|
|
157
|
+
};
|
|
158
|
+
/** Split on whitespace outside parentheses, so `rgba(0, 0, 0, 0)` is one part. */
|
|
159
|
+
function splitShorthand(value) {
|
|
160
|
+
const parts = [];
|
|
161
|
+
let depth = 0;
|
|
162
|
+
let current = "";
|
|
163
|
+
for (const ch of value.trim()) {
|
|
164
|
+
if (ch === "(") depth++;
|
|
165
|
+
if (ch === ")") depth = Math.max(0, depth - 1);
|
|
166
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
167
|
+
if (current) parts.push(current);
|
|
168
|
+
current = "";
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
current += ch;
|
|
172
|
+
}
|
|
173
|
+
if (current) parts.push(current);
|
|
174
|
+
return parts;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A `border-top` shorthand, its parts in any order. `width` is the room the
|
|
178
|
+
* border takes, so a `none` or `hidden` border has none. `drawn` is false when
|
|
179
|
+
* the border paints nothing: a stated 0 width, a `none` or `hidden` style, or a
|
|
180
|
+
* transparent colour. A shorthand without a width reads as 0 wide but drawn.
|
|
181
|
+
*/
|
|
72
182
|
function parseBorderTop(value) {
|
|
73
183
|
if (!value) return {
|
|
74
184
|
width: 0,
|
|
75
185
|
style: "solid",
|
|
76
|
-
color: "#000000"
|
|
186
|
+
color: "#000000",
|
|
187
|
+
drawn: true
|
|
77
188
|
};
|
|
78
|
-
|
|
189
|
+
let width;
|
|
190
|
+
let style;
|
|
191
|
+
let colorPart;
|
|
192
|
+
for (const part of splitShorthand(value)) {
|
|
193
|
+
const lower = part.toLowerCase();
|
|
194
|
+
const length = lower.match(/^(\d+(?:\.\d+)?|\.\d+)(px)?$/);
|
|
195
|
+
if (width === void 0 && length && (length[2] || Number(length[1]) === 0)) width = Number(length[1]);
|
|
196
|
+
else if (width === void 0 && Object.hasOwn(BORDER_WIDTH_KEYWORDS, lower)) width = BORDER_WIDTH_KEYWORDS[lower];
|
|
197
|
+
else if (style === void 0 && BORDER_STYLES.has(lower)) style = lower;
|
|
198
|
+
else colorPart ??= part;
|
|
199
|
+
}
|
|
200
|
+
const hidden = style === "none" || style === "hidden";
|
|
79
201
|
return {
|
|
80
|
-
width:
|
|
81
|
-
style:
|
|
82
|
-
color: parseColor(
|
|
202
|
+
width: hidden || width === void 0 ? 0 : Math.round(width),
|
|
203
|
+
style: style ?? "solid",
|
|
204
|
+
color: parseColor(colorPart) || "#000000",
|
|
205
|
+
drawn: width !== 0 && !hidden && !isTransparentColor(colorPart)
|
|
83
206
|
};
|
|
84
207
|
}
|
|
85
208
|
function extractPadding(style) {
|
|
@@ -89,14 +212,24 @@ function extractPadding(style) {
|
|
|
89
212
|
bottom: 0,
|
|
90
213
|
left: 0
|
|
91
214
|
};
|
|
92
|
-
if (style.padding) return parseShorthandPadding(style.padding);
|
|
215
|
+
if (style.padding && !hasNegativeLength(style.padding)) return parseShorthandPadding(style.padding);
|
|
93
216
|
return {
|
|
94
|
-
top:
|
|
95
|
-
right:
|
|
96
|
-
bottom:
|
|
97
|
-
left:
|
|
217
|
+
top: paddingLonghand(style["padding-top"]),
|
|
218
|
+
right: paddingLonghand(style["padding-right"]),
|
|
219
|
+
bottom: paddingLonghand(style["padding-bottom"]),
|
|
220
|
+
left: paddingLonghand(style["padding-left"])
|
|
98
221
|
};
|
|
99
222
|
}
|
|
223
|
+
function paddingLonghand(value) {
|
|
224
|
+
return Math.max(0, parsePxValue(value));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Whether a padding value holds a negative length, read before rounding:
|
|
228
|
+
* `-0.4px` rounds to `-0`, which would pass as valid. A literal `-0` is 0.
|
|
229
|
+
*/
|
|
230
|
+
function hasNegativeLength(value) {
|
|
231
|
+
return value.trim().split(/\s+/).some((part) => parseFloat(part) < 0);
|
|
232
|
+
}
|
|
100
233
|
function parseShorthandPadding(value) {
|
|
101
234
|
const values = value.trim().split(/\s+/).map((p) => parsePxValue(p));
|
|
102
235
|
switch (values.length) {
|
|
@@ -350,8 +483,32 @@ const MODULE_TYPE_MAP = {
|
|
|
350
483
|
"mailup-bee-newsletter-modules-social": "social",
|
|
351
484
|
"mailup-bee-newsletter-modules-video": "video",
|
|
352
485
|
"mailup-bee-newsletter-modules-menu": "menu",
|
|
353
|
-
"mailup-bee-newsletter-modules-table": "table"
|
|
486
|
+
"mailup-bee-newsletter-modules-table": "table",
|
|
487
|
+
"mailup-bee-newsletter-modules-icons": "icons"
|
|
354
488
|
};
|
|
489
|
+
const ADDON_MODULE = "mailup-bee-newsletter-modules-addon";
|
|
490
|
+
const EMPTY_MODULE = "mailup-bee-newsletter-modules-empty";
|
|
491
|
+
/**
|
|
492
|
+
* An addon keeps the payload of the module it stands in for, so the payload it
|
|
493
|
+
* carries names the converter. Checked in this order.
|
|
494
|
+
*/
|
|
495
|
+
const ADDON_PAYLOADS = [
|
|
496
|
+
["image", "image"],
|
|
497
|
+
["html", "html"],
|
|
498
|
+
["heading", "title"],
|
|
499
|
+
["paragraph", "paragraph"],
|
|
500
|
+
["text", "paragraph"],
|
|
501
|
+
["list", "list"],
|
|
502
|
+
["button", "button"],
|
|
503
|
+
["divider", "divider"],
|
|
504
|
+
["menu", "menu"],
|
|
505
|
+
["iconsList", "icons"]
|
|
506
|
+
];
|
|
507
|
+
/** The converter a module goes to, or `undefined` when there is none. */
|
|
508
|
+
function moduleKind(module) {
|
|
509
|
+
if (module.type !== ADDON_MODULE) return MODULE_TYPE_MAP[module.type];
|
|
510
|
+
return ADDON_PAYLOADS.find(([key]) => module.descriptor[key] !== void 0)?.[1];
|
|
511
|
+
}
|
|
355
512
|
const SOCIAL_PLATFORM_MAP = {
|
|
356
513
|
facebook: "facebook",
|
|
357
514
|
twitter: "twitter",
|
|
@@ -514,32 +671,65 @@ function convertHeading(descriptor, textColor) {
|
|
|
514
671
|
}
|
|
515
672
|
/**
|
|
516
673
|
* The classes BeeFree records in `computedStyle.class` for an image: its
|
|
517
|
-
* alignment
|
|
674
|
+
* alignment, `autowidth` / `fixedwidth`, and in older exports `fullwidth`.
|
|
518
675
|
*/
|
|
519
676
|
function computedClasses(descriptor) {
|
|
520
677
|
const value = descriptor.computedStyle?.class;
|
|
521
678
|
return typeof value === "string" ? value.trim().split(/\s+/) : [];
|
|
522
679
|
}
|
|
523
680
|
/**
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
|
|
527
|
-
|
|
681
|
+
* A px length BeeFree computed: `"172.5px"`, or a bare number in older
|
|
682
|
+
* exports. 0 when it isn't one.
|
|
683
|
+
*/
|
|
684
|
+
function computedPx(value) {
|
|
685
|
+
if (typeof value === "number") return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
|
|
686
|
+
return typeof value === "string" ? parsePxValue(value) : 0;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The width BeeFree shows an image at, as `ImageBlock.width`. BeeFree records
|
|
690
|
+
* it in `computedStyle.width` for both classes: a `fixedwidth` image at the
|
|
691
|
+
* width the author set, an `autowidth` one at its file's width capped at the
|
|
692
|
+
* column. `image.width` and `image.height` are the file's own size, the
|
|
693
|
+
* fallback for an export without `computedStyle`. No width, or one that
|
|
528
694
|
* reaches `room` (the column less the image's side padding), is `"full"`.
|
|
695
|
+
* Older exports class an image that fills its column `fullwidth`, and its
|
|
696
|
+
* computed width leaves out the column padding the import drops, so the class
|
|
697
|
+
* decides. A computed percentage under 100% becomes px of `room`, with a note:
|
|
698
|
+
* px keeps its size on a phone, where the percentage would shrink.
|
|
529
699
|
*/
|
|
530
700
|
function resolveImageWidth(descriptor, room) {
|
|
531
|
-
if (computedClasses(descriptor).includes("
|
|
532
|
-
const
|
|
533
|
-
|
|
701
|
+
if (computedClasses(descriptor).includes("fullwidth")) return { width: "full" };
|
|
702
|
+
const percent = computedPercent(descriptor.computedStyle?.width);
|
|
703
|
+
if (percent !== void 0) {
|
|
704
|
+
if (percent >= 100) return { width: "full" };
|
|
705
|
+
const width = room === void 0 ? "full" : shownWidth(Math.round(room * percent / 100), room);
|
|
706
|
+
return {
|
|
707
|
+
width,
|
|
708
|
+
note: `Image width ${percent}% was converted to ${width === "full" ? "full width" : `${width}px`}.`
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
return { width: shownWidth(computedPx(descriptor.computedStyle?.width) || parsePxValue(descriptor.image?.width), room) };
|
|
712
|
+
}
|
|
713
|
+
/** A percentage BeeFree computed (`"50%"`), or `undefined` when it isn't one. */
|
|
714
|
+
function computedPercent(value) {
|
|
715
|
+
if (typeof value !== "string") return void 0;
|
|
716
|
+
const match = /^(\d+(?:\.\d+)?|\.\d+)%$/.exec(value.trim());
|
|
717
|
+
return match ? parseFloat(match[1]) : void 0;
|
|
718
|
+
}
|
|
719
|
+
/** A shown px width as `ImageBlock.width`: `"full"` when unknown or at `room`. */
|
|
720
|
+
function shownWidth(shown, room) {
|
|
534
721
|
if (shown <= 0) return "full";
|
|
535
722
|
return room !== void 0 && shown >= room ? "full" : shown;
|
|
536
723
|
}
|
|
537
724
|
function convertImage(descriptor, columnWidth) {
|
|
538
725
|
const image = descriptor.image;
|
|
539
726
|
const styles = makeStyles(descriptor);
|
|
540
|
-
if (!image) return
|
|
727
|
+
if (!image) return {
|
|
728
|
+
block: createImageBlock({ styles }),
|
|
729
|
+
notes: []
|
|
730
|
+
};
|
|
541
731
|
const room = columnWidth === void 0 ? void 0 : columnWidth - styles.padding.left - styles.padding.right;
|
|
542
|
-
const width = resolveImageWidth(descriptor, room);
|
|
732
|
+
const { width, note } = resolveImageWidth(descriptor, room);
|
|
543
733
|
const fileWidth = parsePxValue(image.width);
|
|
544
734
|
const fileHeight = parsePxValue(image.height);
|
|
545
735
|
const shownWidth = width === "full" ? room : width;
|
|
@@ -551,19 +741,22 @@ function convertImage(descriptor, columnWidth) {
|
|
|
551
741
|
"center",
|
|
552
742
|
"right"
|
|
553
743
|
].find((side) => classes.includes(side));
|
|
554
|
-
return
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
744
|
+
return {
|
|
745
|
+
block: createImageBlock({
|
|
746
|
+
src: image.src || "",
|
|
747
|
+
alt: image.alt || "",
|
|
748
|
+
width,
|
|
749
|
+
...fileWidth > 0 && fileHeight > 0 ? {
|
|
750
|
+
naturalWidth: fileWidth,
|
|
751
|
+
naturalHeight: fileHeight
|
|
752
|
+
} : {},
|
|
753
|
+
borderRadius: parseImageBorderRadius(image.style?.["border-radius"], stated ? shownWidth : void 0, shownHeight),
|
|
754
|
+
align: toAlign(image.style?.["text-align"] ?? classAlign, "center"),
|
|
755
|
+
linkUrl: image.href || void 0,
|
|
756
|
+
styles
|
|
757
|
+
}),
|
|
758
|
+
notes: note ? [note] : []
|
|
759
|
+
};
|
|
567
760
|
}
|
|
568
761
|
function convertButton(descriptor) {
|
|
569
762
|
const button = descriptor.button;
|
|
@@ -639,6 +832,35 @@ function dividerAlign(descriptor) {
|
|
|
639
832
|
const computed = descriptor.computedStyle?.align;
|
|
640
833
|
return typeof computed === "string" ? computed : descriptor.style?.["text-align"];
|
|
641
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* The room a divider that draws no line takes: its top and bottom padding plus
|
|
837
|
+
* the width of a transparent border. `undefined` when the line is drawn.
|
|
838
|
+
* BeeFree templates space content this way.
|
|
839
|
+
*/
|
|
840
|
+
function undrawnDividerHeight(descriptor) {
|
|
841
|
+
const border = parseBorderTop(descriptor.divider?.style?.["border-top"]);
|
|
842
|
+
if (border.drawn) return void 0;
|
|
843
|
+
const padding = extractPadding(descriptor.style);
|
|
844
|
+
return padding.top + border.width + padding.bottom;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* A divider that draws no line, as a spacer of the same height. A spacer
|
|
848
|
+
* renders no padding, so the divider's padding goes into the height.
|
|
849
|
+
*/
|
|
850
|
+
function convertUndrawnDivider(descriptor, height) {
|
|
851
|
+
return createSpacerBlock({
|
|
852
|
+
height,
|
|
853
|
+
styles: {
|
|
854
|
+
...makeStyles(descriptor),
|
|
855
|
+
padding: {
|
|
856
|
+
top: 0,
|
|
857
|
+
right: 0,
|
|
858
|
+
bottom: 0,
|
|
859
|
+
left: 0
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
642
864
|
function convertSpacer(descriptor) {
|
|
643
865
|
const spacer = descriptor.spacer;
|
|
644
866
|
const height = parsePxValue(spacer?.style?.height) || 24;
|
|
@@ -676,13 +898,170 @@ function convertSocial(descriptor, warnings) {
|
|
|
676
898
|
styles: makeStyles(descriptor)
|
|
677
899
|
});
|
|
678
900
|
}
|
|
901
|
+
/** Escape `& < > "` for text content and attribute values. */
|
|
902
|
+
function escapeHtml(value) {
|
|
903
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
904
|
+
}
|
|
905
|
+
function stringField(value) {
|
|
906
|
+
return typeof value === "string" ? value : "";
|
|
907
|
+
}
|
|
908
|
+
function iconItems(descriptor) {
|
|
909
|
+
const icons = descriptor.iconsList?.icons;
|
|
910
|
+
return Array.isArray(icons) ? icons : [];
|
|
911
|
+
}
|
|
912
|
+
/** The padding BeeFree keeps around each icon, `computedStyle.iconSpacing`. */
|
|
913
|
+
function iconSpacing(descriptor) {
|
|
914
|
+
const value = descriptor.computedStyle?.iconSpacing;
|
|
915
|
+
if (typeof value !== "object" || value === null) return extractPadding({});
|
|
916
|
+
return extractPadding(Object.fromEntries(Object.entries(value).filter(([, side]) => typeof side === "string")));
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* The height BeeFree draws every icon at, and an icon's width at that height,
|
|
920
|
+
* from its file's size. The width is `undefined` when the size isn't stated.
|
|
921
|
+
*/
|
|
922
|
+
function iconSize(descriptor, item) {
|
|
923
|
+
const height = computedPx(descriptor.computedStyle?.iconHeight) || 32;
|
|
924
|
+
const fileWidth = parsePxValue(stringField(item.width));
|
|
925
|
+
const fileHeight = parsePxValue(stringField(item.height));
|
|
926
|
+
return {
|
|
927
|
+
height,
|
|
928
|
+
width: fileWidth > 0 && fileHeight > 0 ? Math.round(height * fileWidth / fileHeight) : void 0
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* A lone icon without text, as an image block at the icon height. `undefined`
|
|
933
|
+
* when the module holds anything else, or the icon's file size is unknown.
|
|
934
|
+
*/
|
|
935
|
+
function convertSingleIcon(descriptor, columnWidth) {
|
|
936
|
+
const items = iconItems(descriptor);
|
|
937
|
+
if (items.length !== 1) return void 0;
|
|
938
|
+
const [item] = items;
|
|
939
|
+
const src = stringField(item.image);
|
|
940
|
+
const { width } = iconSize(descriptor, item);
|
|
941
|
+
if (!src || stringField(item.text) || width === void 0) return void 0;
|
|
942
|
+
const styles = makeStyles(descriptor);
|
|
943
|
+
const spacing = iconSpacing(descriptor);
|
|
944
|
+
const padding = {
|
|
945
|
+
top: styles.padding.top + spacing.top,
|
|
946
|
+
right: styles.padding.right + spacing.right,
|
|
947
|
+
bottom: styles.padding.bottom + spacing.bottom,
|
|
948
|
+
left: styles.padding.left + spacing.left
|
|
949
|
+
};
|
|
950
|
+
const room = columnWidth === void 0 ? void 0 : columnWidth - padding.left - padding.right;
|
|
951
|
+
return createImageBlock({
|
|
952
|
+
src,
|
|
953
|
+
alt: stringField(item.alt),
|
|
954
|
+
width: shownWidth(width, room),
|
|
955
|
+
naturalWidth: parsePxValue(stringField(item.width)),
|
|
956
|
+
naturalHeight: parsePxValue(stringField(item.height)),
|
|
957
|
+
align: toAlign(descriptor.style?.["text-align"]),
|
|
958
|
+
linkUrl: stringField(item.href) || void 0,
|
|
959
|
+
styles: {
|
|
960
|
+
...styles,
|
|
961
|
+
padding
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
/** The module's text style as CSS declarations, for the icons' text cells. */
|
|
966
|
+
function iconTextStyle(style) {
|
|
967
|
+
const color = parseColor(style.color);
|
|
968
|
+
const fontFamily = canonicalFontStack(style["font-family"]);
|
|
969
|
+
const fontSize = parsePxValue(style["font-size"]);
|
|
970
|
+
const fontWeight = style["font-weight"];
|
|
971
|
+
return [
|
|
972
|
+
color ? `color: ${color};` : "",
|
|
973
|
+
fontFamily ? `font-family: ${fontFamily};` : "",
|
|
974
|
+
fontSize ? `font-size: ${fontSize}px;` : "",
|
|
975
|
+
fontWeight ? `font-weight: ${fontWeight};` : ""
|
|
976
|
+
].filter(Boolean).join(" ");
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* The Icons module as the table BeeFree draws: the icons side by side, each at
|
|
980
|
+
* the icon height inside its `iconSpacing`, with its text on the side
|
|
981
|
+
* `textPosition` names, in the module's text style.
|
|
982
|
+
*/
|
|
983
|
+
function iconsHtml(descriptor) {
|
|
984
|
+
const style = descriptor.style ?? {};
|
|
985
|
+
const computed = descriptor.computedStyle ?? {};
|
|
986
|
+
const align = toAlign(style["text-align"]);
|
|
987
|
+
const color = parseColor(style.color);
|
|
988
|
+
const textStyle = iconTextStyle(style);
|
|
989
|
+
const gap = computedPx(computed.itemsSpacing) || computedPx(computed.itemSpacing);
|
|
990
|
+
const spacing = iconSpacing(descriptor);
|
|
991
|
+
const iconPadding = spacing.top || spacing.right || spacing.bottom || spacing.left ? `padding: ${spacing.top}px ${spacing.right}px ${spacing.bottom}px ${spacing.left}px; ` : "";
|
|
992
|
+
const items = iconItems(descriptor).map((item, index) => {
|
|
993
|
+
const position = stringField(item.textPosition);
|
|
994
|
+
const stacked = position === "top" || position === "bottom";
|
|
995
|
+
const cellAlign = stacked ? " align=\"center\"" : "";
|
|
996
|
+
const href = stringField(item.href);
|
|
997
|
+
const target = stringField(item.target);
|
|
998
|
+
const link = (inner, linkStyle) => href ? `<a href="${escapeHtml(href)}"${target ? ` target="${escapeHtml(target)}"` : ""} style="${escapeHtml(linkStyle)}">${inner}</a>` : inner;
|
|
999
|
+
const src = stringField(item.image);
|
|
1000
|
+
const { height, width } = iconSize(descriptor, item);
|
|
1001
|
+
const title = stringField(item.title);
|
|
1002
|
+
const imgStyle = [
|
|
1003
|
+
"display: block;",
|
|
1004
|
+
stacked ? "margin: 0 auto;" : "",
|
|
1005
|
+
width === void 0 ? "" : `width: ${width}px;`,
|
|
1006
|
+
`height: ${height}px;`,
|
|
1007
|
+
"border: 0;"
|
|
1008
|
+
].filter(Boolean).join(" ");
|
|
1009
|
+
const img = `<img src="${escapeHtml(src)}" alt="${escapeHtml(stringField(item.alt))}"${title ? ` title="${escapeHtml(title)}"` : ""}${width === void 0 ? "" : ` width="${width}"`} height="${height}" style="${imgStyle}">`;
|
|
1010
|
+
const iconCell = src ? `<td${cellAlign} style="${iconPadding}vertical-align: middle;">${link(img, "text-decoration: none;")}</td>` : "";
|
|
1011
|
+
const text = stringField(item.text);
|
|
1012
|
+
const textCell = text ? `<td${cellAlign} style="vertical-align: middle;${textStyle ? ` ${escapeHtml(textStyle)}` : ""}">${link(escapeHtml(text), `${color ? `color: ${color}; ` : ""}text-decoration: none;`)}</td>` : "";
|
|
1013
|
+
const body = (position === "top" ? [textCell, iconCell] : position === "bottom" ? [iconCell, textCell] : [position === "left" ? textCell + iconCell : iconCell + textCell]).filter(Boolean).map((row) => `<tr>${row}</tr>`).join("");
|
|
1014
|
+
return `<td style="${index > 0 && gap ? `padding-left: ${gap}px; ` : ""}vertical-align: middle;"><table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">${body}</table></td>`;
|
|
1015
|
+
});
|
|
1016
|
+
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0"${align === "left" ? "" : ` align="${align}"`} style="border-collapse: collapse;"><tr>${items.join("")}</tr></table>`;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The Icons module's items without the link and image URLs the editor's HTML
|
|
1020
|
+
* policy rejects (`javascript:`, `vbscript:`, a non-image `data:`), each named
|
|
1021
|
+
* in `warnings`. Both outputs carry these URLs into markup, and an HTML block
|
|
1022
|
+
* renders unsanitized.
|
|
1023
|
+
*/
|
|
1024
|
+
function safeIconItems(descriptor, warnings) {
|
|
1025
|
+
return iconItems(descriptor).map((item) => {
|
|
1026
|
+
const safe = { ...item };
|
|
1027
|
+
const image = stringField(item.image);
|
|
1028
|
+
if (image && !isSafeHtmlUrl(image, "src")) {
|
|
1029
|
+
warnings.push(`Dropped the unsafe image "${image}" on an Icons item.`);
|
|
1030
|
+
safe.image = "";
|
|
1031
|
+
}
|
|
1032
|
+
const href = stringField(item.href);
|
|
1033
|
+
if (href && !isSafeHtmlUrl(href, "href")) {
|
|
1034
|
+
warnings.push(`Dropped the unsafe link "${href}" on an Icons item.`);
|
|
1035
|
+
safe.href = "";
|
|
1036
|
+
}
|
|
1037
|
+
return safe;
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
function convertIcons(descriptor, columnWidth, warnings) {
|
|
1041
|
+
const safe = {
|
|
1042
|
+
...descriptor,
|
|
1043
|
+
iconsList: { icons: safeIconItems(descriptor, warnings) }
|
|
1044
|
+
};
|
|
1045
|
+
const single = convertSingleIcon(safe, columnWidth);
|
|
1046
|
+
if (single) return {
|
|
1047
|
+
block: single,
|
|
1048
|
+
rebuilt: false
|
|
1049
|
+
};
|
|
1050
|
+
return {
|
|
1051
|
+
block: createHtmlBlock({
|
|
1052
|
+
content: iconsHtml(safe),
|
|
1053
|
+
styles: makeStyles(descriptor)
|
|
1054
|
+
}),
|
|
1055
|
+
rebuilt: true
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
679
1058
|
function convertVideo(descriptor) {
|
|
680
1059
|
const video = descriptor.video;
|
|
681
1060
|
if (!video) return createVideoBlock({ styles: makeStyles(descriptor) });
|
|
682
1061
|
return createVideoBlock({
|
|
683
1062
|
url: video.src || "",
|
|
684
1063
|
thumbnailUrl: video.thumbnail || "",
|
|
685
|
-
alt: video.alt
|
|
1064
|
+
alt: video.thumbAlt ?? video.alt ?? "",
|
|
686
1065
|
width: parsePxValue(video.style?.width) || 600,
|
|
687
1066
|
height: parsePxValue(video.style?.height) || void 0,
|
|
688
1067
|
align: toAlign(video.style?.["text-align"], "center"),
|
|
@@ -764,8 +1143,8 @@ function convertHtmlFallback(module) {
|
|
|
764
1143
|
* Converts a single BeeFree module to a Templatical block.
|
|
765
1144
|
* Returns the block and a report entry.
|
|
766
1145
|
*/
|
|
767
|
-
function
|
|
768
|
-
const mappedType =
|
|
1146
|
+
function convertModuleBase(module, warnings, context = {}) {
|
|
1147
|
+
const mappedType = moduleKind(module);
|
|
769
1148
|
const descriptor = module.descriptor;
|
|
770
1149
|
const textColor = context.textColor ?? "#1a1a1a";
|
|
771
1150
|
if (!mappedType) return {
|
|
@@ -774,7 +1153,7 @@ function convertModule(module, warnings, context = {}) {
|
|
|
774
1153
|
beeFreeModuleType: module.type,
|
|
775
1154
|
templaticalBlockType: "html",
|
|
776
1155
|
status: "html-fallback",
|
|
777
|
-
note: `Unknown module type "${module.type}" converted to HTML block.`
|
|
1156
|
+
note: module.type === ADDON_MODULE ? "The addon carries no content Templatical reads; converted to an HTML block." : `Unknown module type "${module.type}" converted to HTML block.`
|
|
778
1157
|
}
|
|
779
1158
|
};
|
|
780
1159
|
let block;
|
|
@@ -788,13 +1167,23 @@ function convertModule(module, warnings, context = {}) {
|
|
|
788
1167
|
case "title":
|
|
789
1168
|
block = convertHeading(descriptor, textColor);
|
|
790
1169
|
break;
|
|
791
|
-
case "image":
|
|
792
|
-
|
|
1170
|
+
case "image": {
|
|
1171
|
+
const result = convertImage(descriptor, context.columnWidth);
|
|
1172
|
+
block = result.block;
|
|
1173
|
+
notes = result.notes;
|
|
1174
|
+
isApproximation = notes.length > 0;
|
|
793
1175
|
break;
|
|
1176
|
+
}
|
|
794
1177
|
case "button":
|
|
795
1178
|
block = convertButton(descriptor);
|
|
796
1179
|
break;
|
|
797
1180
|
case "divider": {
|
|
1181
|
+
const height = undrawnDividerHeight(descriptor);
|
|
1182
|
+
if (height !== void 0) {
|
|
1183
|
+
block = convertUndrawnDivider(descriptor, height);
|
|
1184
|
+
notes = [`The divider draws no line; imported as a ${height}px spacer.`];
|
|
1185
|
+
break;
|
|
1186
|
+
}
|
|
798
1187
|
const result = convertDivider(descriptor, context.columnWidth);
|
|
799
1188
|
block = result.block;
|
|
800
1189
|
notes = result.notes;
|
|
@@ -804,6 +1193,20 @@ function convertModule(module, warnings, context = {}) {
|
|
|
804
1193
|
case "spacer":
|
|
805
1194
|
block = convertSpacer(descriptor);
|
|
806
1195
|
break;
|
|
1196
|
+
case "icons": {
|
|
1197
|
+
const result = convertIcons(descriptor, context.columnWidth, warnings);
|
|
1198
|
+
if (result.rebuilt) return {
|
|
1199
|
+
block: result.block,
|
|
1200
|
+
entry: {
|
|
1201
|
+
beeFreeModuleType: module.type,
|
|
1202
|
+
templaticalBlockType: "html",
|
|
1203
|
+
status: "html-fallback",
|
|
1204
|
+
note: "Icons module rebuilt as an HTML table."
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
block = result.block;
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
807
1210
|
case "html":
|
|
808
1211
|
block = convertHtml(descriptor);
|
|
809
1212
|
break;
|
|
@@ -847,6 +1250,79 @@ function convertModule(module, warnings, context = {}) {
|
|
|
847
1250
|
}
|
|
848
1251
|
};
|
|
849
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* The report entry for a module that imports no block, or `undefined` when it
|
|
1255
|
+
* converts: an empty module, an Icons module with no icons, and a divider that
|
|
1256
|
+
* draws no line and takes no space.
|
|
1257
|
+
*/
|
|
1258
|
+
function skippedModuleEntry(module) {
|
|
1259
|
+
const skip = (note) => ({
|
|
1260
|
+
beeFreeModuleType: module.type,
|
|
1261
|
+
templaticalBlockType: null,
|
|
1262
|
+
status: "skipped",
|
|
1263
|
+
note
|
|
1264
|
+
});
|
|
1265
|
+
if (module.type === EMPTY_MODULE) return skip("The module is empty.");
|
|
1266
|
+
switch (moduleKind(module)) {
|
|
1267
|
+
case "icons": return iconItems(module.descriptor).length === 0 ? skip("The Icons module has no icons.") : void 0;
|
|
1268
|
+
case "divider": return undrawnDividerHeight(module.descriptor) === 0 ? skip("The divider draws no line and takes no space.") : void 0;
|
|
1269
|
+
default: return;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
function convertModule(module, warnings, context = {}) {
|
|
1273
|
+
const result = convertModuleBase(module, warnings, context);
|
|
1274
|
+
const { block } = result;
|
|
1275
|
+
const descriptor = module.descriptor;
|
|
1276
|
+
const payloads = [
|
|
1277
|
+
["heading", descriptor.heading],
|
|
1278
|
+
["text", descriptor.text],
|
|
1279
|
+
["paragraph", descriptor.paragraph],
|
|
1280
|
+
["list", descriptor.list],
|
|
1281
|
+
["button", descriptor.button],
|
|
1282
|
+
["image", descriptor.image],
|
|
1283
|
+
["video", descriptor.video],
|
|
1284
|
+
["menu", descriptor.menu]
|
|
1285
|
+
];
|
|
1286
|
+
const active = payloads.find(([, payload]) => payload !== void 0);
|
|
1287
|
+
const sources = [module.translations, ...payloads.map(([, payload]) => payload?.translations)];
|
|
1288
|
+
for (const translations of sources) for (const [code, values] of Object.entries(translations ?? {})) {
|
|
1289
|
+
const locale = localeTag(code, warnings);
|
|
1290
|
+
if (!locale) continue;
|
|
1291
|
+
if (result.entry.status === "html-fallback" || block.type === "html") {
|
|
1292
|
+
warn(warnings, `Dropped language "${locale}" on module "${module.type}": target converted to HTML fallback.`);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const key = active?.[0];
|
|
1296
|
+
const field = block.type === "title" ? "text" : block.type === "paragraph" ? "html" : block.type === "button" ? "label" : block.type === "image" ? "alt" : block.type === "video" ? "thumbAlt" : block.type === "menu" ? "items" : void 0;
|
|
1297
|
+
for (const unknown of Object.keys(values).filter((name) => name !== field)) warn(warnings, `Dropped language "${locale}" key "${unknown}": no language mapping for this format.`);
|
|
1298
|
+
if (!field || values[field] === void 0) continue;
|
|
1299
|
+
if (block.type === "menu" && Array.isArray(values.items)) {
|
|
1300
|
+
if (values.items.length !== block.items.length) {
|
|
1301
|
+
warn(warnings, `Dropped language "${locale}" menu translation: item count differs from the source.`);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
values.items.forEach((item, index) => {
|
|
1305
|
+
for (const key of Object.keys(item ?? {}).filter((key) => key !== "text")) warn(warnings, `Dropped language "${locale}" key "${key}": no language mapping for this format.`);
|
|
1306
|
+
if (typeof item?.text === "string") text(block, locale, `items.${block.items[index].id}.text`, block.items[index].text, item.text);
|
|
1307
|
+
});
|
|
1308
|
+
} else if (key && typeof values[field] === "string") {
|
|
1309
|
+
const translated = convertModuleBase({
|
|
1310
|
+
...module,
|
|
1311
|
+
descriptor: {
|
|
1312
|
+
...descriptor,
|
|
1313
|
+
[key]: {
|
|
1314
|
+
...descriptor[key],
|
|
1315
|
+
[field]: values[field]
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}, [], context).block;
|
|
1319
|
+
if ((block.type === "title" || block.type === "paragraph") && (translated.type === "title" || translated.type === "paragraph")) text(block, locale, "content", block.content, translated.content);
|
|
1320
|
+
if (block.type === "button" && translated.type === "button") text(block, locale, "text", block.text, translated.text);
|
|
1321
|
+
if ((block.type === "image" || block.type === "video") && (translated.type === "image" || translated.type === "video")) text(block, locale, "alt", block.alt, translated.alt ?? "");
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return result;
|
|
1325
|
+
}
|
|
850
1326
|
// src/converter.ts
|
|
851
1327
|
/**
|
|
852
1328
|
* Determines the Templatical ColumnLayout from BeeFree column grid values.
|
|
@@ -890,6 +1366,11 @@ function columnPixels(layout, bodyWidth) {
|
|
|
890
1366
|
function convertColumnModules(column, context, entries, warnings) {
|
|
891
1367
|
const blocks = [];
|
|
892
1368
|
for (const module of column.modules) {
|
|
1369
|
+
const skipped = skippedModuleEntry(module);
|
|
1370
|
+
if (skipped) {
|
|
1371
|
+
entries.push(skipped);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
893
1374
|
const { block, entry } = convertModule(module, warnings, context);
|
|
894
1375
|
blocks.push(block);
|
|
895
1376
|
entries.push(entry);
|
|
@@ -1017,7 +1498,7 @@ function extractSettings(template) {
|
|
|
1017
1498
|
* console.log(report.warnings);
|
|
1018
1499
|
* ```
|
|
1019
1500
|
*/
|
|
1020
|
-
function convertBeeFreeTemplate(template) {
|
|
1501
|
+
function convertBeeFreeTemplate(template, options = {}) {
|
|
1021
1502
|
if (!template?.page?.rows) throw new Error("Invalid BeeFree template: missing page.rows. Ensure you are passing a valid BeeFree JSON export.");
|
|
1022
1503
|
const entries = [];
|
|
1023
1504
|
const warnings = [];
|
|
@@ -1025,16 +1506,32 @@ function convertBeeFreeTemplate(template) {
|
|
|
1025
1506
|
const webFonts = template.page.body?.webFonts;
|
|
1026
1507
|
if (webFonts && webFonts.length > 1) warnings.push(`Template uses ${webFonts.length} web fonts. Only the primary font is preserved; additional fonts may need to be configured via the fonts option.`);
|
|
1027
1508
|
const settings = extractSettings(template);
|
|
1509
|
+
const declared = template.page.body?.language?.value ?? template.page.language?.value ?? template.language?.value;
|
|
1510
|
+
settings.locale = (declared ? localeTag(declared, warnings) : null) ?? defaultLocaleOption(options.defaultLocale, warnings) ?? "en";
|
|
1511
|
+
const meta = template.head?.meta;
|
|
1512
|
+
if (typeof meta?.preheader === "string") settings.preheaderText = meta.preheader;
|
|
1513
|
+
for (const [code, values] of Object.entries(meta?.translations ?? {})) {
|
|
1514
|
+
const locale = localeTag(code, warnings);
|
|
1515
|
+
if (!locale) continue;
|
|
1516
|
+
for (const key of Object.keys(values)) if (key === "preheader" && typeof values.preheader === "string") text(settings, locale, "preheaderText", settings.preheaderText, values.preheader);
|
|
1517
|
+
else warn(warnings, `Dropped language "${locale}" metadata "${key}": ${[
|
|
1518
|
+
"subject",
|
|
1519
|
+
"title",
|
|
1520
|
+
"description"
|
|
1521
|
+
].includes(key) ? "the host owns this field" : "no language mapping for this format"}.`);
|
|
1522
|
+
}
|
|
1028
1523
|
for (const row of template.page.rows) {
|
|
1029
1524
|
if (row.empty) continue;
|
|
1030
1525
|
blocks.push(...processRow(row, settings, entries, warnings));
|
|
1031
1526
|
}
|
|
1527
|
+
const content = {
|
|
1528
|
+
...createDefaultTemplateContent(),
|
|
1529
|
+
blocks,
|
|
1530
|
+
settings
|
|
1531
|
+
};
|
|
1532
|
+
registerLocales(content);
|
|
1032
1533
|
return {
|
|
1033
|
-
content
|
|
1034
|
-
...createDefaultTemplateContent(),
|
|
1035
|
-
blocks,
|
|
1036
|
-
settings
|
|
1037
|
-
},
|
|
1534
|
+
content,
|
|
1038
1535
|
report: {
|
|
1039
1536
|
entries,
|
|
1040
1537
|
warnings,
|