@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.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
- const parts = value.trim().split(/\s+/);
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: parsePxValue(parts[0]),
81
- style: parts[1] || "solid",
82
- color: parseColor(parts[2]) || "#000000"
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: parsePxValue(style["padding-top"]),
95
- right: parsePxValue(style["padding-right"]),
96
- bottom: parsePxValue(style["padding-bottom"]),
97
- left: parsePxValue(style["padding-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 and `autowidth` / `fixedwidth`.
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
- * The width BeeFree shows an image at, as `ImageBlock.width`. `image.width` and
525
- * `image.height` are the file's own size: an `autowidth` image fills its
526
- * column, and a `fixedwidth` one is shown at `computedStyle.width`. An export
527
- * without `computedStyle` falls back to `image.width`. No width, or one that
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("autowidth")) return "full";
532
- const computed = descriptor.computedStyle?.width;
533
- const shown = parsePxValue(typeof computed === "string" ? computed : void 0) || parsePxValue(descriptor.image?.width);
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 createImageBlock({ styles });
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 createImageBlock({
555
- src: image.src || "",
556
- alt: image.alt || "",
557
- width,
558
- ...fileWidth > 0 && fileHeight > 0 ? {
559
- naturalWidth: fileWidth,
560
- naturalHeight: fileHeight
561
- } : {},
562
- borderRadius: parseImageBorderRadius(image.style?.["border-radius"], stated ? shownWidth : void 0, shownHeight),
563
- align: toAlign(image.style?.["text-align"] ?? classAlign, "center"),
564
- linkUrl: image.href || void 0,
565
- styles
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
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 convertModule(module, warnings, context = {}) {
768
- const mappedType = MODULE_TYPE_MAP[module.type];
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
- block = convertImage(descriptor, context.columnWidth);
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,