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