@maily-to/migration 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +709 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +35 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +35 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +707 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +52 -0
- package/readme.md +50 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _maily_to_shared = require("@maily-to/shared");
|
|
3
|
+
//#region src/utils/border.ts
|
|
4
|
+
/**
|
|
5
|
+
* Expands a single border-radius value into the four individual
|
|
6
|
+
* corner properties used by v2 nodes: borderTopLeftRadius,
|
|
7
|
+
* borderTopRightRadius, borderBottomRightRadius, borderBottomLeftRadius.
|
|
8
|
+
* In v1, border radius was stored as a single number; v2 stores
|
|
9
|
+
* each corner independently to support non-uniform rounding.
|
|
10
|
+
*/
|
|
11
|
+
function splitBorderRadius(radius) {
|
|
12
|
+
return {
|
|
13
|
+
borderTopLeftRadius: radius,
|
|
14
|
+
borderTopRightRadius: radius,
|
|
15
|
+
borderBottomRightRadius: radius,
|
|
16
|
+
borderBottomLeftRadius: radius
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Expands a single border-width value into the four individual
|
|
21
|
+
* side properties used by v2 nodes: borderTopWidth, borderRightWidth,
|
|
22
|
+
* borderBottomWidth, borderLeftWidth. In v1, border width was stored
|
|
23
|
+
* as a single number; v2 stores each side independently to support
|
|
24
|
+
* non-uniform borders.
|
|
25
|
+
*/
|
|
26
|
+
function splitBorderWidth(width) {
|
|
27
|
+
return {
|
|
28
|
+
borderTopWidth: width,
|
|
29
|
+
borderRightWidth: width,
|
|
30
|
+
borderBottomWidth: width,
|
|
31
|
+
borderLeftWidth: width
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/transforms/button.ts
|
|
36
|
+
const BORDER_RADIUS_MAP = {
|
|
37
|
+
sharp: 0,
|
|
38
|
+
smooth: 6,
|
|
39
|
+
round: 9999
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Migrates a v1 button node to the v2 schema.
|
|
43
|
+
* Performs the following conversions:
|
|
44
|
+
* - Moves `attrs.text` into `content` as a text node (or a variable
|
|
45
|
+
* node when `isTextVariable` is true).
|
|
46
|
+
* - Renames `buttonColor` → `backgroundColor`, `textColor` → `color`.
|
|
47
|
+
* - Converts `variant: 'outline'` to `backgroundColor: 'transparent'`.
|
|
48
|
+
* - Converts the `borderRadius` string enum (sharp/smooth/round) into
|
|
49
|
+
* four numeric corner properties via splitBorderRadius.
|
|
50
|
+
* - Sets v2 defaults: kind "tight", paddingMode "mixed",
|
|
51
|
+
* borderRadiusMode/borderWidthMode "uniform", borderStyle "solid".
|
|
52
|
+
* - Drops deprecated flags: isTextVariable, isUrlVariable, variant.
|
|
53
|
+
*/
|
|
54
|
+
function button(node, warnings) {
|
|
55
|
+
const attrs = node.attrs ?? {};
|
|
56
|
+
if (!node.content) {
|
|
57
|
+
const text = attrs.text ?? "";
|
|
58
|
+
if (attrs.isTextVariable && text) node.content = [{
|
|
59
|
+
type: "variable",
|
|
60
|
+
attrs: { id: text }
|
|
61
|
+
}];
|
|
62
|
+
else if (text) node.content = [{
|
|
63
|
+
type: "text",
|
|
64
|
+
text
|
|
65
|
+
}];
|
|
66
|
+
}
|
|
67
|
+
if ("text" in attrs) {
|
|
68
|
+
warnings.push({
|
|
69
|
+
nodeType: "button",
|
|
70
|
+
field: "text",
|
|
71
|
+
message: "Migrated \"text\" → inline content node"
|
|
72
|
+
});
|
|
73
|
+
delete attrs.text;
|
|
74
|
+
}
|
|
75
|
+
if ("isTextVariable" in attrs) {
|
|
76
|
+
warnings.push({
|
|
77
|
+
nodeType: "button",
|
|
78
|
+
field: "isTextVariable",
|
|
79
|
+
message: "Migrated \"isTextVariable\" → variable content node"
|
|
80
|
+
});
|
|
81
|
+
delete attrs.isTextVariable;
|
|
82
|
+
}
|
|
83
|
+
if ("buttonColor" in attrs) {
|
|
84
|
+
warnings.push({
|
|
85
|
+
nodeType: "button",
|
|
86
|
+
field: "buttonColor",
|
|
87
|
+
message: "Migrated \"buttonColor\" → \"backgroundColor\""
|
|
88
|
+
});
|
|
89
|
+
attrs.backgroundColor = attrs.buttonColor;
|
|
90
|
+
delete attrs.buttonColor;
|
|
91
|
+
}
|
|
92
|
+
if ("textColor" in attrs) {
|
|
93
|
+
warnings.push({
|
|
94
|
+
nodeType: "button",
|
|
95
|
+
field: "textColor",
|
|
96
|
+
message: "Migrated \"textColor\" → \"color\""
|
|
97
|
+
});
|
|
98
|
+
attrs.color = attrs.textColor;
|
|
99
|
+
delete attrs.textColor;
|
|
100
|
+
}
|
|
101
|
+
if (attrs.variant === "outline") {
|
|
102
|
+
warnings.push({
|
|
103
|
+
nodeType: "button",
|
|
104
|
+
field: "variant",
|
|
105
|
+
message: "Migrated \"variant: outline\" → transparent backgroundColor + border properties"
|
|
106
|
+
});
|
|
107
|
+
attrs.borderColor = attrs.backgroundColor ?? "#000000";
|
|
108
|
+
attrs.borderTopWidth = 2;
|
|
109
|
+
attrs.borderRightWidth = 2;
|
|
110
|
+
attrs.borderBottomWidth = 2;
|
|
111
|
+
attrs.borderLeftWidth = 2;
|
|
112
|
+
attrs.backgroundColor = "transparent";
|
|
113
|
+
}
|
|
114
|
+
delete attrs.variant;
|
|
115
|
+
if (typeof attrs.borderRadius === "string") {
|
|
116
|
+
warnings.push({
|
|
117
|
+
nodeType: "button",
|
|
118
|
+
field: "borderRadius",
|
|
119
|
+
message: "Migrated \"borderRadius\" enum → individual corner radius properties"
|
|
120
|
+
});
|
|
121
|
+
const radius = BORDER_RADIUS_MAP[attrs.borderRadius] ?? 9999;
|
|
122
|
+
Object.assign(attrs, splitBorderRadius(radius));
|
|
123
|
+
delete attrs.borderRadius;
|
|
124
|
+
}
|
|
125
|
+
attrs.kind ??= "tight";
|
|
126
|
+
attrs.paddingMode ??= "mixed";
|
|
127
|
+
attrs.borderRadiusMode ??= "uniform";
|
|
128
|
+
attrs.borderWidthMode ??= "uniform";
|
|
129
|
+
attrs.borderStyle ??= "solid";
|
|
130
|
+
attrs.borderColor ??= "#000000";
|
|
131
|
+
attrs.borderTopWidth ??= 0;
|
|
132
|
+
attrs.borderRightWidth ??= 0;
|
|
133
|
+
attrs.borderBottomWidth ??= 0;
|
|
134
|
+
attrs.borderLeftWidth ??= 0;
|
|
135
|
+
if ("isUrlVariable" in attrs) {
|
|
136
|
+
warnings.push({
|
|
137
|
+
nodeType: "button",
|
|
138
|
+
field: "isUrlVariable",
|
|
139
|
+
message: "Attribute \"isUrlVariable\" is not supported in v2 and was dropped"
|
|
140
|
+
});
|
|
141
|
+
delete attrs.isUrlVariable;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/transforms/column.ts
|
|
146
|
+
const DROPPED_COLUMN_ATTRS = [
|
|
147
|
+
"columnId",
|
|
148
|
+
"backgroundColor",
|
|
149
|
+
"borderRadius",
|
|
150
|
+
"borderWidth",
|
|
151
|
+
"borderColor",
|
|
152
|
+
"paddingTop",
|
|
153
|
+
"paddingRight",
|
|
154
|
+
"paddingBottom",
|
|
155
|
+
"paddingLeft",
|
|
156
|
+
"visibilityRule"
|
|
157
|
+
];
|
|
158
|
+
/**
|
|
159
|
+
* Migrates a v1 column node to the v2 schema.
|
|
160
|
+
* Performs the following conversions:
|
|
161
|
+
* - Converts `width: 'auto'` → `null` (v2 uses null for equal-space columns).
|
|
162
|
+
* - Converts percentage width strings (e.g. "50%") to numbers (e.g. 50).
|
|
163
|
+
* - Drops v1 styling attributes that are not supported on columns in v2:
|
|
164
|
+
* columnId, backgroundColor, borderRadius, borderWidth, borderColor,
|
|
165
|
+
* paddingTop/Right/Bottom/Left, and visibilityRule (converted from showIfKey
|
|
166
|
+
* by the global transform). Each dropped attribute emits a warning.
|
|
167
|
+
*/
|
|
168
|
+
function column(node, warnings) {
|
|
169
|
+
const attrs = node.attrs ?? {};
|
|
170
|
+
if (attrs.width === "auto") attrs.width = null;
|
|
171
|
+
else if (typeof attrs.width === "string") {
|
|
172
|
+
const parsed = parseFloat(attrs.width);
|
|
173
|
+
attrs.width = Number.isNaN(parsed) ? null : parsed;
|
|
174
|
+
}
|
|
175
|
+
for (const field of DROPPED_COLUMN_ATTRS) if (field in attrs && attrs[field] != null) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
nodeType: "column",
|
|
178
|
+
field,
|
|
179
|
+
message: `Column attribute "${field}" is not supported in v2 and was dropped`
|
|
180
|
+
});
|
|
181
|
+
delete attrs[field];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/transforms/columns.ts
|
|
186
|
+
/**
|
|
187
|
+
* Migrates a v1 columns node to the v2 schema.
|
|
188
|
+
* In v2, columns tracks its child count and gap explicitly:
|
|
189
|
+
* - Sets `columnCount` to the number of column children in `content`.
|
|
190
|
+
* - Sets `gap` to the default value of 8 if not already present.
|
|
191
|
+
*/
|
|
192
|
+
function columns(node, _warnings) {
|
|
193
|
+
const attrs = node.attrs ?? {};
|
|
194
|
+
attrs.columnCount ??= node.content?.length ?? 0;
|
|
195
|
+
attrs.gap ??= 8;
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/transforms/footer.ts
|
|
199
|
+
/**
|
|
200
|
+
* Migrates a v1 footer node to the v2 schema.
|
|
201
|
+
* Removes the `maily-component` attribute which was used in v1
|
|
202
|
+
* as a marker to identify the footer node. In v2, the node type
|
|
203
|
+
* alone is sufficient for identification.
|
|
204
|
+
*/
|
|
205
|
+
function footer(node, warnings) {
|
|
206
|
+
const attrs = node.attrs ?? {};
|
|
207
|
+
if ("maily-component" in attrs) {
|
|
208
|
+
warnings.push({
|
|
209
|
+
nodeType: "footer",
|
|
210
|
+
field: "maily-component",
|
|
211
|
+
message: "v1 marker attribute \"maily-component\" is not needed in v2 and was dropped"
|
|
212
|
+
});
|
|
213
|
+
delete attrs["maily-component"];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/utils/id.ts
|
|
218
|
+
/**
|
|
219
|
+
* Generates a unique identifier for a node.
|
|
220
|
+
* Uses the Web Crypto API to produce a v4 UUID string,
|
|
221
|
+
* e.g. "3b241101-e2bb-4d7a-8613-e4d4e2f1b9c1".
|
|
222
|
+
*/
|
|
223
|
+
function uid() {
|
|
224
|
+
return crypto.randomUUID();
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/transforms/global.ts
|
|
228
|
+
/**
|
|
229
|
+
* Applies transforms common to every node in the document tree.
|
|
230
|
+
* Handles three conversions that apply regardless of node type:
|
|
231
|
+
* 1. showIfKey → visibilityRule: converts the v1 conditional
|
|
232
|
+
* visibility string into a structured v2 VisibilityRule object
|
|
233
|
+
* with action "show", operator "is_true".
|
|
234
|
+
* 2. textDirection → dir: renames the attribute to match the v2
|
|
235
|
+
* schema (mirrors the HTML `dir` attribute).
|
|
236
|
+
* 3. id generation: assigns a crypto UUID to any node that lacks
|
|
237
|
+
* an id, except for `doc` and `text` nodes which don't use ids.
|
|
238
|
+
*/
|
|
239
|
+
function global(node, warnings) {
|
|
240
|
+
if (node.type === "text") return;
|
|
241
|
+
if (!node.attrs) node.attrs = {};
|
|
242
|
+
const attrs = node.attrs;
|
|
243
|
+
if (attrs.showIfKey) {
|
|
244
|
+
warnings.push({
|
|
245
|
+
nodeType: node.type,
|
|
246
|
+
field: "showIfKey",
|
|
247
|
+
message: "Migrated \"showIfKey\" → \"visibilityRule\""
|
|
248
|
+
});
|
|
249
|
+
attrs.visibilityRule = {
|
|
250
|
+
action: "show",
|
|
251
|
+
variable: attrs.showIfKey,
|
|
252
|
+
operator: "is_true",
|
|
253
|
+
value: ""
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
delete attrs.showIfKey;
|
|
257
|
+
if ("textDirection" in attrs) {
|
|
258
|
+
if (attrs.textDirection) {
|
|
259
|
+
warnings.push({
|
|
260
|
+
nodeType: node.type,
|
|
261
|
+
field: "textDirection",
|
|
262
|
+
message: "Migrated \"textDirection\" → \"dir\""
|
|
263
|
+
});
|
|
264
|
+
attrs.dir = attrs.textDirection;
|
|
265
|
+
}
|
|
266
|
+
delete attrs.textDirection;
|
|
267
|
+
}
|
|
268
|
+
if (node.type !== "doc" && !attrs.id) attrs.id = uid();
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/transforms/html-code-block.ts
|
|
272
|
+
/**
|
|
273
|
+
* Migrates a v1 htmlCodeBlock node to the v2 schema.
|
|
274
|
+
* Strips the `activeTab` attribute which was editor-only UI state
|
|
275
|
+
* persisted into the document JSON.
|
|
276
|
+
*/
|
|
277
|
+
function htmlCodeBlock(node, warnings) {
|
|
278
|
+
const attrs = node.attrs ?? {};
|
|
279
|
+
if ("activeTab" in attrs) {
|
|
280
|
+
warnings.push({
|
|
281
|
+
nodeType: "htmlCodeBlock",
|
|
282
|
+
field: "activeTab",
|
|
283
|
+
message: "Editor-only attribute \"activeTab\" is not supported in v2 and was dropped"
|
|
284
|
+
});
|
|
285
|
+
delete attrs.activeTab;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/transforms/image.ts
|
|
290
|
+
const DEFAULT_CONTAINER_WIDTH$1 = 600;
|
|
291
|
+
/**
|
|
292
|
+
* Migrates a v1 image node to the v2 schema.
|
|
293
|
+
* Performs the following conversions:
|
|
294
|
+
* - Renames `alignment` → `align` to match the v2 attribute name.
|
|
295
|
+
* - Splits the single `borderRadius` number into four corner properties.
|
|
296
|
+
* - Converts `width: 'auto'` → `'100%'` (v2 uses percentage strings).
|
|
297
|
+
* - Sets v2 border defaults: borderRadiusMode/borderWidthMode "uniform",
|
|
298
|
+
* borderStyle "solid", all border widths to 0.
|
|
299
|
+
* - Drops deprecated attrs: height, isSrcVariable, isExternalLinkVariable,
|
|
300
|
+
* lockAspectRatio, aspectRatio.
|
|
301
|
+
*/
|
|
302
|
+
function image(node, warnings) {
|
|
303
|
+
const attrs = node.attrs ?? {};
|
|
304
|
+
if ("alignment" in attrs) {
|
|
305
|
+
warnings.push({
|
|
306
|
+
nodeType: "image",
|
|
307
|
+
field: "alignment",
|
|
308
|
+
message: "Migrated \"alignment\" → \"align\""
|
|
309
|
+
});
|
|
310
|
+
attrs.align = attrs.alignment;
|
|
311
|
+
delete attrs.alignment;
|
|
312
|
+
}
|
|
313
|
+
if (typeof attrs.borderRadius === "number") {
|
|
314
|
+
warnings.push({
|
|
315
|
+
nodeType: "image",
|
|
316
|
+
field: "borderRadius",
|
|
317
|
+
message: "Migrated \"borderRadius\" → individual corner radius properties"
|
|
318
|
+
});
|
|
319
|
+
Object.assign(attrs, splitBorderRadius(attrs.borderRadius));
|
|
320
|
+
delete attrs.borderRadius;
|
|
321
|
+
}
|
|
322
|
+
const container = attrs._containerWidth ?? DEFAULT_CONTAINER_WIDTH$1;
|
|
323
|
+
delete attrs._containerWidth;
|
|
324
|
+
if (attrs.width === "auto") attrs.width = "100%";
|
|
325
|
+
else if (typeof attrs.width === "number" || typeof attrs.width === "string" && !attrs.width.endsWith("%")) {
|
|
326
|
+
const px = Number(attrs.width);
|
|
327
|
+
if (!Number.isNaN(px) && px > 0) attrs.width = `${Math.min(_maily_to_shared.MAX_IMAGE_WIDTH_PERCENTAGE, Math.max(_maily_to_shared.MIN_IMAGE_WIDTH_PERCENTAGE, Math.round(px / container * 100)))}%`;
|
|
328
|
+
}
|
|
329
|
+
attrs.borderRadiusMode ??= "uniform";
|
|
330
|
+
attrs.borderWidthMode ??= "uniform";
|
|
331
|
+
attrs.borderStyle ??= "solid";
|
|
332
|
+
attrs.borderColor ??= "#000000";
|
|
333
|
+
attrs.borderTopWidth ??= 0;
|
|
334
|
+
attrs.borderRightWidth ??= 0;
|
|
335
|
+
attrs.borderBottomWidth ??= 0;
|
|
336
|
+
attrs.borderLeftWidth ??= 0;
|
|
337
|
+
for (const field of [
|
|
338
|
+
"height",
|
|
339
|
+
"isSrcVariable",
|
|
340
|
+
"isExternalLinkVariable",
|
|
341
|
+
"lockAspectRatio",
|
|
342
|
+
"aspectRatio"
|
|
343
|
+
]) if (field in attrs) {
|
|
344
|
+
warnings.push({
|
|
345
|
+
nodeType: "image",
|
|
346
|
+
field,
|
|
347
|
+
message: `Attribute "${field}" is not supported in v2 and was dropped`
|
|
348
|
+
});
|
|
349
|
+
delete attrs[field];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/transforms/inline-image.ts
|
|
354
|
+
/**
|
|
355
|
+
* Migrates a v1 inlineImage node to the v2 schema.
|
|
356
|
+
* Performs the following conversions:
|
|
357
|
+
* - Converts `src: null` or `src: undefined` to an empty string,
|
|
358
|
+
* since v2 expects src to always be a string.
|
|
359
|
+
* - Drops deprecated flags: isSrcVariable, isExternalLinkVariable.
|
|
360
|
+
*/
|
|
361
|
+
function inlineImage(node, warnings) {
|
|
362
|
+
const attrs = node.attrs ?? {};
|
|
363
|
+
if (attrs.src == null) attrs.src = "";
|
|
364
|
+
for (const field of ["isSrcVariable", "isExternalLinkVariable"]) if (field in attrs) {
|
|
365
|
+
warnings.push({
|
|
366
|
+
nodeType: "inlineImage",
|
|
367
|
+
field,
|
|
368
|
+
message: `Attribute "${field}" is not supported in v2 and was dropped`
|
|
369
|
+
});
|
|
370
|
+
delete attrs[field];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/transforms/link.ts
|
|
375
|
+
/**
|
|
376
|
+
* Migrates a v1 link mark to the v2 schema.
|
|
377
|
+
* Removes the `isUrlVariable` flag from the mark attrs.
|
|
378
|
+
* In v1, this boolean indicated whether the href was a variable
|
|
379
|
+
* reference; v2 handles variable detection differently and no
|
|
380
|
+
* longer needs this flag.
|
|
381
|
+
*/
|
|
382
|
+
function link(mark, warnings) {
|
|
383
|
+
const attrs = mark.attrs ?? {};
|
|
384
|
+
if ("isUrlVariable" in attrs) {
|
|
385
|
+
warnings.push({
|
|
386
|
+
nodeType: "link",
|
|
387
|
+
field: "isUrlVariable",
|
|
388
|
+
message: "Attribute \"isUrlVariable\" is not supported in v2 and was dropped"
|
|
389
|
+
});
|
|
390
|
+
delete attrs.isUrlVariable;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region src/transforms/logo.ts
|
|
395
|
+
const LOGO_SIZE_MAP = {
|
|
396
|
+
sm: 40,
|
|
397
|
+
md: 48,
|
|
398
|
+
lg: 64
|
|
399
|
+
};
|
|
400
|
+
const DEFAULT_CONTAINER_WIDTH = 600;
|
|
401
|
+
/**
|
|
402
|
+
* Migrates a v1 logo node by converting it into a v2 image node.
|
|
403
|
+
* The logo node type was removed in v2 — logos are now just images.
|
|
404
|
+
* Performs the following conversions:
|
|
405
|
+
* - Changes `type: 'logo'` → `type: 'image'`.
|
|
406
|
+
* - Maps the `size` enum (sm/md/lg) to a numeric `width` string
|
|
407
|
+
* (40/48/64 pixels respectively).
|
|
408
|
+
* - Renames `alignment` → `align`.
|
|
409
|
+
* - Sets v2 image border defaults (all radii and widths to 0).
|
|
410
|
+
* - Drops deprecated `isSrcVariable` flag.
|
|
411
|
+
*/
|
|
412
|
+
function logo(node, warnings) {
|
|
413
|
+
node.type = "image";
|
|
414
|
+
const attrs = node.attrs ?? {};
|
|
415
|
+
if ("size" in attrs) {
|
|
416
|
+
const px = LOGO_SIZE_MAP[attrs.size] ?? 48;
|
|
417
|
+
const container = attrs._containerWidth ?? DEFAULT_CONTAINER_WIDTH;
|
|
418
|
+
const percent = Math.min(_maily_to_shared.MAX_IMAGE_WIDTH_PERCENTAGE, Math.max(_maily_to_shared.MIN_IMAGE_WIDTH_PERCENTAGE, Math.round(px / container * 100)));
|
|
419
|
+
warnings.push({
|
|
420
|
+
nodeType: "logo",
|
|
421
|
+
field: "size",
|
|
422
|
+
message: `Migrated "size: ${attrs.size}" → "width: ${percent}%"`
|
|
423
|
+
});
|
|
424
|
+
attrs.width = `${percent}%`;
|
|
425
|
+
delete attrs.size;
|
|
426
|
+
}
|
|
427
|
+
delete attrs._containerWidth;
|
|
428
|
+
if ("alignment" in attrs) {
|
|
429
|
+
warnings.push({
|
|
430
|
+
nodeType: "logo",
|
|
431
|
+
field: "alignment",
|
|
432
|
+
message: "Migrated \"alignment\" → \"align\""
|
|
433
|
+
});
|
|
434
|
+
attrs.align = attrs.alignment;
|
|
435
|
+
delete attrs.alignment;
|
|
436
|
+
}
|
|
437
|
+
attrs.borderRadiusMode ??= "uniform";
|
|
438
|
+
attrs.borderWidthMode ??= "uniform";
|
|
439
|
+
attrs.borderStyle ??= "solid";
|
|
440
|
+
attrs.borderColor ??= "#000000";
|
|
441
|
+
attrs.borderTopWidth ??= 0;
|
|
442
|
+
attrs.borderRightWidth ??= 0;
|
|
443
|
+
attrs.borderBottomWidth ??= 0;
|
|
444
|
+
attrs.borderLeftWidth ??= 0;
|
|
445
|
+
attrs.borderTopLeftRadius ??= 0;
|
|
446
|
+
attrs.borderTopRightRadius ??= 0;
|
|
447
|
+
attrs.borderBottomRightRadius ??= 0;
|
|
448
|
+
attrs.borderBottomLeftRadius ??= 0;
|
|
449
|
+
for (const field of ["isSrcVariable", "maily-component"]) if (field in attrs) {
|
|
450
|
+
warnings.push({
|
|
451
|
+
nodeType: "logo",
|
|
452
|
+
field,
|
|
453
|
+
message: `Attribute "${field}" is not supported in v2 and was dropped`
|
|
454
|
+
});
|
|
455
|
+
delete attrs[field];
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/transforms/repeat.ts
|
|
460
|
+
/**
|
|
461
|
+
* Migrates a v1 repeat node to the v2 schema.
|
|
462
|
+
* Wraps the `each` attribute value in Handlebars-style template syntax
|
|
463
|
+
* if it isn't already wrapped. For example, `'items'` becomes `'{{items}}'`.
|
|
464
|
+
* In v2, the each value must be a template expression; v1 allowed
|
|
465
|
+
* bare variable names without delimiters.
|
|
466
|
+
*/
|
|
467
|
+
function repeat(node, warnings) {
|
|
468
|
+
const attrs = node.attrs ?? {};
|
|
469
|
+
if (typeof attrs.each === "string" && attrs.each && !attrs.each.startsWith("{{")) {
|
|
470
|
+
warnings.push({
|
|
471
|
+
nodeType: "repeat",
|
|
472
|
+
field: "each",
|
|
473
|
+
message: `Migrated "each: ${attrs.each}" → "each: {{${attrs.each}}}"`
|
|
474
|
+
});
|
|
475
|
+
attrs.each = `{{${attrs.each}}}`;
|
|
476
|
+
}
|
|
477
|
+
if ("isUpdatingKey" in attrs) {
|
|
478
|
+
warnings.push({
|
|
479
|
+
nodeType: "repeat",
|
|
480
|
+
field: "isUpdatingKey",
|
|
481
|
+
message: "Editor-only attribute \"isUpdatingKey\" is not supported in v2 and was dropped"
|
|
482
|
+
});
|
|
483
|
+
delete attrs.isUpdatingKey;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/transforms/section.ts
|
|
488
|
+
/**
|
|
489
|
+
* Migrates a v1 section node to the v2 schema.
|
|
490
|
+
* Performs the following conversions:
|
|
491
|
+
* - Splits the single `borderRadius` number into four corner properties.
|
|
492
|
+
* - Splits the single `borderWidth` number into four side properties.
|
|
493
|
+
* - Sets v2 mode defaults: borderRadiusMode/borderWidthMode "uniform",
|
|
494
|
+
* paddingMode "uniform", marginMode "mixed", borderStyle "solid".
|
|
495
|
+
*/
|
|
496
|
+
function section(node, warnings) {
|
|
497
|
+
const attrs = node.attrs ?? {};
|
|
498
|
+
if (typeof attrs.borderRadius === "number") {
|
|
499
|
+
warnings.push({
|
|
500
|
+
nodeType: "section",
|
|
501
|
+
field: "borderRadius",
|
|
502
|
+
message: "Migrated \"borderRadius\" → individual corner radius properties"
|
|
503
|
+
});
|
|
504
|
+
Object.assign(attrs, splitBorderRadius(attrs.borderRadius));
|
|
505
|
+
delete attrs.borderRadius;
|
|
506
|
+
}
|
|
507
|
+
if (typeof attrs.borderWidth === "number") {
|
|
508
|
+
warnings.push({
|
|
509
|
+
nodeType: "section",
|
|
510
|
+
field: "borderWidth",
|
|
511
|
+
message: "Migrated \"borderWidth\" → individual side width properties"
|
|
512
|
+
});
|
|
513
|
+
Object.assign(attrs, splitBorderWidth(attrs.borderWidth));
|
|
514
|
+
delete attrs.borderWidth;
|
|
515
|
+
}
|
|
516
|
+
attrs.borderRadiusMode ??= "uniform";
|
|
517
|
+
attrs.borderWidthMode ??= "uniform";
|
|
518
|
+
attrs.paddingMode ??= "uniform";
|
|
519
|
+
attrs.marginMode ??= "mixed";
|
|
520
|
+
attrs.borderStyle ??= "solid";
|
|
521
|
+
attrs.borderColor ??= "#000000";
|
|
522
|
+
}
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/transforms/spacer.ts
|
|
525
|
+
const DEFAULT_SPACER_HEIGHT = 8;
|
|
526
|
+
const SPACING = [
|
|
527
|
+
{
|
|
528
|
+
name: "Extra Small",
|
|
529
|
+
short: "xs",
|
|
530
|
+
value: 4
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "Small",
|
|
534
|
+
short: "sm",
|
|
535
|
+
value: 8
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
name: "Medium",
|
|
539
|
+
short: "md",
|
|
540
|
+
value: 16
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "Large",
|
|
544
|
+
short: "lg",
|
|
545
|
+
value: 32
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: "Extra Large",
|
|
549
|
+
short: "xl",
|
|
550
|
+
value: 64
|
|
551
|
+
}
|
|
552
|
+
];
|
|
553
|
+
const ALLOWED_SPACING_SHORT_NAMES = SPACING.map((s) => s.short);
|
|
554
|
+
/**
|
|
555
|
+
* Migrates a v1 spacer node to the v2 schema.
|
|
556
|
+
* Sets `heightMode` to "uniform" if not already present.
|
|
557
|
+
* In v2, spacers support per-side height control via heightMode;
|
|
558
|
+
* v1 spacers only had a single uniform height value.
|
|
559
|
+
*/
|
|
560
|
+
function spacer(node, warnings) {
|
|
561
|
+
const attrs = node.attrs ?? {};
|
|
562
|
+
let height = node.attrs?.height;
|
|
563
|
+
if (typeof height === "string" && ALLOWED_SPACING_SHORT_NAMES.includes(height)) {
|
|
564
|
+
const spacing = SPACING.find((s) => s.short === height);
|
|
565
|
+
warnings.push({
|
|
566
|
+
nodeType: "spacer",
|
|
567
|
+
field: "height",
|
|
568
|
+
message: `Migrated "height" from string enum to number: ${height} → ${spacing?.value}`
|
|
569
|
+
});
|
|
570
|
+
height = spacing?.value ?? DEFAULT_SPACER_HEIGHT;
|
|
571
|
+
}
|
|
572
|
+
attrs.height = height;
|
|
573
|
+
attrs.heightMode ??= "uniform";
|
|
574
|
+
}
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/utils/container-width.ts
|
|
577
|
+
const ROOT_WIDTH = 600;
|
|
578
|
+
const DEFAULT_COLUMNS_GAP = 8;
|
|
579
|
+
/**
|
|
580
|
+
* Parses a v1 column width attribute.
|
|
581
|
+
* Returns the numeric percentage, or `null` for "auto" / unparseable values.
|
|
582
|
+
*/
|
|
583
|
+
function parseColumnWidth(width) {
|
|
584
|
+
if (typeof width === "string") {
|
|
585
|
+
if (width === "auto") return null;
|
|
586
|
+
const parsed = parseFloat(width);
|
|
587
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
588
|
+
}
|
|
589
|
+
if (typeof width === "number") return width;
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Recursively walks a v1 document tree and stamps `_containerWidth` on every
|
|
594
|
+
* `image` and `logo` node. The value reflects the available content width at
|
|
595
|
+
* that position in the tree, accounting for section padding/border and column
|
|
596
|
+
* percentage widths + gap.
|
|
597
|
+
*
|
|
598
|
+
* Must run **before** the transform walker so the transforms can read
|
|
599
|
+
* `_containerWidth` and convert pixel sizes to accurate percentages.
|
|
600
|
+
*/
|
|
601
|
+
function annotateContainerWidths(node, availableWidth = ROOT_WIDTH) {
|
|
602
|
+
if (!node) return;
|
|
603
|
+
const attrs = node.attrs ?? {};
|
|
604
|
+
if (node.type === "image" || node.type === "logo") {
|
|
605
|
+
if (!node.attrs) node.attrs = {};
|
|
606
|
+
node.attrs._containerWidth = availableWidth;
|
|
607
|
+
}
|
|
608
|
+
let childWidth = availableWidth;
|
|
609
|
+
if (node.type === "section") {
|
|
610
|
+
const paddingLeft = typeof attrs.paddingLeft === "number" ? attrs.paddingLeft : 0;
|
|
611
|
+
const paddingRight = typeof attrs.paddingRight === "number" ? attrs.paddingRight : 0;
|
|
612
|
+
const borderWidth = typeof attrs.borderWidth === "number" ? attrs.borderWidth : 0;
|
|
613
|
+
childWidth = availableWidth - paddingLeft - paddingRight - borderWidth * 2;
|
|
614
|
+
}
|
|
615
|
+
if (node.type === "columns" && Array.isArray(node.content)) {
|
|
616
|
+
const gap = typeof attrs.gap === "number" ? attrs.gap : DEFAULT_COLUMNS_GAP;
|
|
617
|
+
const columns = node.content;
|
|
618
|
+
const columnCount = columns.length;
|
|
619
|
+
let claimedPercent = 0;
|
|
620
|
+
let autoCount = 0;
|
|
621
|
+
for (const col of columns) {
|
|
622
|
+
const colWidth = parseColumnWidth(col.attrs?.width);
|
|
623
|
+
if (colWidth !== null) claimedPercent += colWidth;
|
|
624
|
+
else autoCount++;
|
|
625
|
+
}
|
|
626
|
+
const remainingPercent = Math.max(0, 100 - claimedPercent);
|
|
627
|
+
const autoPercent = autoCount > 0 ? remainingPercent / autoCount : 0;
|
|
628
|
+
for (let i = 0; i < columns.length; i++) {
|
|
629
|
+
const col = columns[i];
|
|
630
|
+
const colPercent = parseColumnWidth(col.attrs?.width) ?? autoPercent;
|
|
631
|
+
let gapDeduction = 0;
|
|
632
|
+
if (columnCount > 1) if (i === 0 || i === columnCount - 1) gapDeduction = gap / 2;
|
|
633
|
+
else gapDeduction = gap;
|
|
634
|
+
const colWidth = childWidth * (colPercent / 100) - gapDeduction;
|
|
635
|
+
annotateContainerWidths(col, Math.max(0, colWidth));
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (Array.isArray(node.content)) for (const child of node.content) annotateContainerWidths(child, childWidth);
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/migrate.ts
|
|
643
|
+
const NODE_TRANSFORMS = {
|
|
644
|
+
button,
|
|
645
|
+
htmlCodeBlock,
|
|
646
|
+
image,
|
|
647
|
+
logo,
|
|
648
|
+
section,
|
|
649
|
+
columns,
|
|
650
|
+
column,
|
|
651
|
+
spacer,
|
|
652
|
+
repeat,
|
|
653
|
+
footer,
|
|
654
|
+
inlineImage
|
|
655
|
+
};
|
|
656
|
+
const MARK_TRANSFORMS = { link };
|
|
657
|
+
/**
|
|
658
|
+
* Checks whether a Maily JSON document needs migration.
|
|
659
|
+
* Returns `true` when the document has no `version` attribute
|
|
660
|
+
* or when `version` is less than 2, indicating it is a v1 document
|
|
661
|
+
* that should be passed through `migrate` before use.
|
|
662
|
+
*/
|
|
663
|
+
function requireContentMigration(json) {
|
|
664
|
+
return !(json.attrs?.version >= 2);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Converts a Maily v1 JSON document to the v2 schema.
|
|
668
|
+
* Deep-clones the input so the original is never mutated, then
|
|
669
|
+
* walks the tree iteratively (stack-based) applying global transforms
|
|
670
|
+
* (showIfKey → visibilityRule, textDirection → dir, id generation)
|
|
671
|
+
* followed by node-specific transforms (button, image, section, etc.)
|
|
672
|
+
* and mark transforms (link).
|
|
673
|
+
*
|
|
674
|
+
* If the document is already v2+ (version >= 2), it is returned as-is
|
|
675
|
+
* with an empty warnings array. Warnings are emitted when v1 attributes
|
|
676
|
+
* are dropped without a v2 equivalent (e.g. column styling attributes).
|
|
677
|
+
*/
|
|
678
|
+
function migrate(json) {
|
|
679
|
+
if (!requireContentMigration(json)) return {
|
|
680
|
+
json,
|
|
681
|
+
warnings: []
|
|
682
|
+
};
|
|
683
|
+
const doc = structuredClone(json);
|
|
684
|
+
const warnings = [];
|
|
685
|
+
if (!doc.attrs) doc.attrs = {};
|
|
686
|
+
doc.attrs.version = 2;
|
|
687
|
+
annotateContainerWidths(doc);
|
|
688
|
+
const stack = [doc];
|
|
689
|
+
while (stack.length > 0) {
|
|
690
|
+
const node = stack.pop();
|
|
691
|
+
global(node, warnings);
|
|
692
|
+
const transform = NODE_TRANSFORMS[node.type];
|
|
693
|
+
if (transform) transform(node, warnings);
|
|
694
|
+
if (Array.isArray(node.marks)) for (const mark of node.marks) {
|
|
695
|
+
const markTransform = MARK_TRANSFORMS[mark.type];
|
|
696
|
+
if (markTransform) markTransform(mark, warnings);
|
|
697
|
+
}
|
|
698
|
+
if (Array.isArray(node.content)) for (let i = node.content.length - 1; i >= 0; i--) stack.push(node.content[i]);
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
json: doc,
|
|
702
|
+
warnings
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
//#endregion
|
|
706
|
+
exports.migrate = migrate;
|
|
707
|
+
exports.requireContentMigration = requireContentMigration;
|
|
708
|
+
|
|
709
|
+
//# sourceMappingURL=index.cjs.map
|