@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 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