@maily-to/migration 2.0.0-beta.7 → 2.0.0-beta.8

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