@templatical/template-tools 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1278 @@
1
+ import Ajv from "ajv";
2
+ import { lintTemplate } from "@templatical/quality";
3
+ import { safeClone } from "@templatical/types";
4
+ //#endregion
5
+ //#region src/validate.ts
6
+ /** The generated JSON Schema for `TemplateContent`, as a plain object. */
7
+ const schema = {
8
+ $id: "https://templatical.com/schema/template-content.json",
9
+ $schema: "http://json-schema.org/draft-07/schema#",
10
+ $ref: "#/definitions/TemplateContent",
11
+ definitions: {
12
+ "TemplateContent": {
13
+ "type": "object",
14
+ "properties": {
15
+ "blocks": {
16
+ "type": "array",
17
+ "items": { "$ref": "#/definitions/Block" }
18
+ },
19
+ "settings": { "$ref": "#/definitions/TemplateSettings" }
20
+ },
21
+ "required": ["blocks", "settings"],
22
+ "additionalProperties": false
23
+ },
24
+ "Block": { "anyOf": [
25
+ { "$ref": "#/definitions/SectionBlock" },
26
+ { "$ref": "#/definitions/TitleBlock" },
27
+ { "$ref": "#/definitions/ParagraphBlock" },
28
+ { "$ref": "#/definitions/ImageBlock" },
29
+ { "$ref": "#/definitions/ButtonBlock" },
30
+ { "$ref": "#/definitions/DividerBlock" },
31
+ { "$ref": "#/definitions/VideoBlock" },
32
+ { "$ref": "#/definitions/SocialIconsBlock" },
33
+ { "$ref": "#/definitions/SpacerBlock" },
34
+ { "$ref": "#/definitions/HtmlBlock" },
35
+ { "$ref": "#/definitions/MenuBlock" },
36
+ { "$ref": "#/definitions/TableBlock" },
37
+ { "$ref": "#/definitions/CountdownBlock" },
38
+ { "$ref": "#/definitions/CustomBlock" }
39
+ ] },
40
+ "SectionBlock": {
41
+ "type": "object",
42
+ "properties": {
43
+ "id": { "type": "string" },
44
+ "type": {
45
+ "type": "string",
46
+ "const": "section"
47
+ },
48
+ "styles": { "$ref": "#/definitions/BlockStyles" },
49
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
50
+ "displayCondition": {
51
+ "type": "object",
52
+ "properties": {
53
+ "label": { "type": "string" },
54
+ "before": { "type": "string" },
55
+ "after": { "type": "string" },
56
+ "group": { "type": "string" },
57
+ "description": { "type": "string" }
58
+ },
59
+ "required": [
60
+ "label",
61
+ "before",
62
+ "after"
63
+ ],
64
+ "additionalProperties": false
65
+ },
66
+ "columns": { "$ref": "#/definitions/ColumnLayout" },
67
+ "children": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "array",
71
+ "items": { "$ref": "#/definitions/Block" }
72
+ }
73
+ },
74
+ "stackOnMobile": {
75
+ "type": "boolean",
76
+ "description": "Whether columns stack vertically on mobile. Absent or `true` keeps MJML's default responsive behavior (columns stack below 480px). `false` renders the columns inside an `<mj-group>` so they stay side-by-side on mobile, proportionally shrunk to fit."
77
+ },
78
+ "borderRadius": {
79
+ "type": "number",
80
+ "description": "Corner radius in px. Omitted/0 = square corners."
81
+ },
82
+ "wrapper": {
83
+ "$ref": "#/definitions/SectionWrapper",
84
+ "description": "Optional outer frame (rendered as an `mj-wrapper` around the section)."
85
+ }
86
+ },
87
+ "required": [
88
+ "children",
89
+ "columns",
90
+ "id",
91
+ "styles",
92
+ "type"
93
+ ],
94
+ "additionalProperties": false
95
+ },
96
+ "BlockStyles": {
97
+ "type": "object",
98
+ "properties": {
99
+ "padding": { "$ref": "#/definitions/SpacingValue" },
100
+ "backgroundColor": { "type": "string" }
101
+ },
102
+ "required": ["padding"],
103
+ "additionalProperties": false
104
+ },
105
+ "SpacingValue": {
106
+ "type": "object",
107
+ "properties": {
108
+ "top": { "type": "number" },
109
+ "right": { "type": "number" },
110
+ "bottom": { "type": "number" },
111
+ "left": { "type": "number" }
112
+ },
113
+ "required": [
114
+ "top",
115
+ "right",
116
+ "bottom",
117
+ "left"
118
+ ],
119
+ "additionalProperties": false
120
+ },
121
+ "BlockVisibility": {
122
+ "type": "object",
123
+ "properties": {
124
+ "desktop": { "type": "boolean" },
125
+ "mobile": { "type": "boolean" }
126
+ },
127
+ "required": ["desktop", "mobile"],
128
+ "additionalProperties": false
129
+ },
130
+ "ColumnLayout": {
131
+ "type": "string",
132
+ "enum": [
133
+ "1",
134
+ "2",
135
+ "3",
136
+ "2-1",
137
+ "1-2"
138
+ ]
139
+ },
140
+ "SectionWrapper": {
141
+ "type": "object",
142
+ "properties": {
143
+ "backgroundColor": { "type": "string" },
144
+ "padding": { "$ref": "#/definitions/SpacingValue" },
145
+ "borderRadius": {
146
+ "type": "number",
147
+ "description": "Corner radius in px for the outer frame. Omitted/0 = square corners."
148
+ }
149
+ },
150
+ "additionalProperties": false,
151
+ "description": "Optional outer frame for a section. When present, the section is rendered inside an `mj-wrapper` — a full-width band (its own background + padding) that frames the section, e.g. a white card sitting on a colored band."
152
+ },
153
+ "TitleBlock": {
154
+ "type": "object",
155
+ "properties": {
156
+ "id": { "type": "string" },
157
+ "type": {
158
+ "type": "string",
159
+ "const": "title"
160
+ },
161
+ "styles": { "$ref": "#/definitions/BlockStyles" },
162
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
163
+ "displayCondition": {
164
+ "type": "object",
165
+ "properties": {
166
+ "label": { "type": "string" },
167
+ "before": { "type": "string" },
168
+ "after": { "type": "string" },
169
+ "group": { "type": "string" },
170
+ "description": { "type": "string" }
171
+ },
172
+ "required": [
173
+ "label",
174
+ "before",
175
+ "after"
176
+ ],
177
+ "additionalProperties": false
178
+ },
179
+ "content": { "type": "string" },
180
+ "level": { "$ref": "#/definitions/HeadingLevel" },
181
+ "color": {
182
+ "type": "string",
183
+ "description": "Text color. Unset = inherit the document-level `textColor`."
184
+ },
185
+ "textAlign": {
186
+ "type": "string",
187
+ "enum": [
188
+ "left",
189
+ "center",
190
+ "right"
191
+ ]
192
+ },
193
+ "fontFamily": { "type": "string" }
194
+ },
195
+ "required": [
196
+ "content",
197
+ "id",
198
+ "level",
199
+ "styles",
200
+ "textAlign",
201
+ "type"
202
+ ],
203
+ "additionalProperties": false
204
+ },
205
+ "HeadingLevel": {
206
+ "type": "number",
207
+ "enum": [
208
+ 1,
209
+ 2,
210
+ 3,
211
+ 4
212
+ ]
213
+ },
214
+ "ParagraphBlock": {
215
+ "type": "object",
216
+ "properties": {
217
+ "id": { "type": "string" },
218
+ "type": {
219
+ "type": "string",
220
+ "const": "paragraph"
221
+ },
222
+ "styles": { "$ref": "#/definitions/BlockStyles" },
223
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
224
+ "displayCondition": {
225
+ "type": "object",
226
+ "properties": {
227
+ "label": { "type": "string" },
228
+ "before": { "type": "string" },
229
+ "after": { "type": "string" },
230
+ "group": { "type": "string" },
231
+ "description": { "type": "string" }
232
+ },
233
+ "required": [
234
+ "label",
235
+ "before",
236
+ "after"
237
+ ],
238
+ "additionalProperties": false
239
+ },
240
+ "content": { "type": "string" },
241
+ "paragraphSpacing": {
242
+ "type": "number",
243
+ "description": "Gap in px between this block's paragraphs — the space below every `<p>` except the last. Absent means `RICH_TEXT_SPACING.paragraphGap`.\n\nOnly affects a block holding more than one paragraph; a single `<p>` has no internal gap, and the space around the block is `styles.padding`.\n\n`0` is a valid choice (paragraphs butted together) and is distinct from the field being absent, so readers must test for `undefined` rather than falsiness."
244
+ }
245
+ },
246
+ "required": [
247
+ "content",
248
+ "id",
249
+ "styles",
250
+ "type"
251
+ ],
252
+ "additionalProperties": false
253
+ },
254
+ "ImageBlock": {
255
+ "type": "object",
256
+ "properties": {
257
+ "id": { "type": "string" },
258
+ "type": {
259
+ "type": "string",
260
+ "const": "image"
261
+ },
262
+ "styles": { "$ref": "#/definitions/BlockStyles" },
263
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
264
+ "displayCondition": {
265
+ "type": "object",
266
+ "properties": {
267
+ "label": { "type": "string" },
268
+ "before": { "type": "string" },
269
+ "after": { "type": "string" },
270
+ "group": { "type": "string" },
271
+ "description": { "type": "string" }
272
+ },
273
+ "required": [
274
+ "label",
275
+ "before",
276
+ "after"
277
+ ],
278
+ "additionalProperties": false
279
+ },
280
+ "src": { "type": "string" },
281
+ "alt": { "type": "string" },
282
+ "width": { "anyOf": [{ "type": "number" }, {
283
+ "type": "string",
284
+ "const": "full"
285
+ }] },
286
+ "height": {
287
+ "type": "number",
288
+ "description": "Height in pixels. Absent means the height is derived from the width, so the image keeps its aspect ratio — setting both stretches it, since email clients don't support `object-fit`."
289
+ },
290
+ "align": {
291
+ "type": "string",
292
+ "enum": [
293
+ "left",
294
+ "center",
295
+ "right"
296
+ ]
297
+ },
298
+ "borderRadius": {
299
+ "type": "number",
300
+ "description": "Corner radius in px. Omitted/0 = square corners. A radius of at least half the rendered size rounds a square image to a circle, which is how avatar and portrait layouts are built."
301
+ },
302
+ "linkUrl": { "type": "string" },
303
+ "linkOpenInNewTab": { "type": "boolean" },
304
+ "placeholderUrl": { "type": "string" },
305
+ "decorative": { "type": "boolean" }
306
+ },
307
+ "required": [
308
+ "align",
309
+ "alt",
310
+ "id",
311
+ "src",
312
+ "styles",
313
+ "type",
314
+ "width"
315
+ ],
316
+ "additionalProperties": false
317
+ },
318
+ "ButtonBlock": {
319
+ "type": "object",
320
+ "properties": {
321
+ "id": { "type": "string" },
322
+ "type": {
323
+ "type": "string",
324
+ "const": "button"
325
+ },
326
+ "styles": { "$ref": "#/definitions/BlockStyles" },
327
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
328
+ "displayCondition": {
329
+ "type": "object",
330
+ "properties": {
331
+ "label": { "type": "string" },
332
+ "before": { "type": "string" },
333
+ "after": { "type": "string" },
334
+ "group": { "type": "string" },
335
+ "description": { "type": "string" }
336
+ },
337
+ "required": [
338
+ "label",
339
+ "before",
340
+ "after"
341
+ ],
342
+ "additionalProperties": false
343
+ },
344
+ "text": { "type": "string" },
345
+ "url": { "type": "string" },
346
+ "openInNewTab": { "type": "boolean" },
347
+ "backgroundColor": { "type": "string" },
348
+ "textColor": { "type": "string" },
349
+ "borderRadius": { "type": "number" },
350
+ "fontSize": { "type": "number" },
351
+ "buttonPadding": { "$ref": "#/definitions/SpacingValue" },
352
+ "fontFamily": { "type": "string" },
353
+ "width": { "anyOf": [{ "type": "number" }, {
354
+ "type": "string",
355
+ "const": "full"
356
+ }] },
357
+ "align": {
358
+ "type": "string",
359
+ "enum": [
360
+ "left",
361
+ "center",
362
+ "right"
363
+ ],
364
+ "description": "Placement of the button within its column. No visible effect when `width` is `\"full\"`, since the button then spans the column."
365
+ }
366
+ },
367
+ "required": [
368
+ "align",
369
+ "backgroundColor",
370
+ "borderRadius",
371
+ "buttonPadding",
372
+ "fontSize",
373
+ "id",
374
+ "styles",
375
+ "text",
376
+ "textColor",
377
+ "type",
378
+ "url"
379
+ ],
380
+ "additionalProperties": false
381
+ },
382
+ "DividerBlock": {
383
+ "type": "object",
384
+ "properties": {
385
+ "id": { "type": "string" },
386
+ "type": {
387
+ "type": "string",
388
+ "const": "divider"
389
+ },
390
+ "styles": { "$ref": "#/definitions/BlockStyles" },
391
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
392
+ "displayCondition": {
393
+ "type": "object",
394
+ "properties": {
395
+ "label": { "type": "string" },
396
+ "before": { "type": "string" },
397
+ "after": { "type": "string" },
398
+ "group": { "type": "string" },
399
+ "description": { "type": "string" }
400
+ },
401
+ "required": [
402
+ "label",
403
+ "before",
404
+ "after"
405
+ ],
406
+ "additionalProperties": false
407
+ },
408
+ "lineStyle": {
409
+ "type": "string",
410
+ "enum": [
411
+ "solid",
412
+ "dashed",
413
+ "dotted"
414
+ ]
415
+ },
416
+ "color": { "type": "string" },
417
+ "thickness": { "type": "number" },
418
+ "width": { "anyOf": [{ "type": "number" }, {
419
+ "type": "string",
420
+ "const": "full"
421
+ }] }
422
+ },
423
+ "required": [
424
+ "color",
425
+ "id",
426
+ "lineStyle",
427
+ "styles",
428
+ "thickness",
429
+ "type",
430
+ "width"
431
+ ],
432
+ "additionalProperties": false
433
+ },
434
+ "VideoBlock": {
435
+ "type": "object",
436
+ "properties": {
437
+ "id": { "type": "string" },
438
+ "type": {
439
+ "type": "string",
440
+ "const": "video"
441
+ },
442
+ "styles": { "$ref": "#/definitions/BlockStyles" },
443
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
444
+ "displayCondition": {
445
+ "type": "object",
446
+ "properties": {
447
+ "label": { "type": "string" },
448
+ "before": { "type": "string" },
449
+ "after": { "type": "string" },
450
+ "group": { "type": "string" },
451
+ "description": { "type": "string" }
452
+ },
453
+ "required": [
454
+ "label",
455
+ "before",
456
+ "after"
457
+ ],
458
+ "additionalProperties": false
459
+ },
460
+ "url": { "type": "string" },
461
+ "openInNewTab": { "type": "boolean" },
462
+ "thumbnailUrl": { "type": "string" },
463
+ "alt": { "type": "string" },
464
+ "width": { "anyOf": [{ "type": "number" }, {
465
+ "type": "string",
466
+ "const": "full"
467
+ }] },
468
+ "height": {
469
+ "type": "number",
470
+ "description": "Height in pixels for the thumbnail. Absent means the height is derived from the width, so the thumbnail keeps its aspect ratio — setting both stretches it, since email clients don't support `object-fit`."
471
+ },
472
+ "align": {
473
+ "type": "string",
474
+ "enum": [
475
+ "left",
476
+ "center",
477
+ "right"
478
+ ]
479
+ },
480
+ "placeholderUrl": { "type": "string" }
481
+ },
482
+ "required": [
483
+ "align",
484
+ "alt",
485
+ "id",
486
+ "styles",
487
+ "thumbnailUrl",
488
+ "type",
489
+ "url",
490
+ "width"
491
+ ],
492
+ "additionalProperties": false
493
+ },
494
+ "SocialIconsBlock": {
495
+ "type": "object",
496
+ "properties": {
497
+ "id": { "type": "string" },
498
+ "type": {
499
+ "type": "string",
500
+ "const": "social"
501
+ },
502
+ "styles": { "$ref": "#/definitions/BlockStyles" },
503
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
504
+ "displayCondition": {
505
+ "type": "object",
506
+ "properties": {
507
+ "label": { "type": "string" },
508
+ "before": { "type": "string" },
509
+ "after": { "type": "string" },
510
+ "group": { "type": "string" },
511
+ "description": { "type": "string" }
512
+ },
513
+ "required": [
514
+ "label",
515
+ "before",
516
+ "after"
517
+ ],
518
+ "additionalProperties": false
519
+ },
520
+ "icons": {
521
+ "type": "array",
522
+ "items": { "$ref": "#/definitions/SocialIcon" }
523
+ },
524
+ "iconStyle": { "$ref": "#/definitions/SocialIconStyle" },
525
+ "iconSize": { "$ref": "#/definitions/SocialIconSize" },
526
+ "spacing": { "type": "number" },
527
+ "align": {
528
+ "type": "string",
529
+ "enum": [
530
+ "left",
531
+ "center",
532
+ "right"
533
+ ]
534
+ }
535
+ },
536
+ "required": [
537
+ "align",
538
+ "iconSize",
539
+ "iconStyle",
540
+ "icons",
541
+ "id",
542
+ "spacing",
543
+ "styles",
544
+ "type"
545
+ ],
546
+ "additionalProperties": false
547
+ },
548
+ "SocialIcon": {
549
+ "type": "object",
550
+ "properties": {
551
+ "id": { "type": "string" },
552
+ "platform": { "$ref": "#/definitions/SocialPlatform" },
553
+ "url": { "type": "string" }
554
+ },
555
+ "required": [
556
+ "id",
557
+ "platform",
558
+ "url"
559
+ ],
560
+ "additionalProperties": false
561
+ },
562
+ "SocialPlatform": {
563
+ "type": "string",
564
+ "enum": [
565
+ "facebook",
566
+ "twitter",
567
+ "instagram",
568
+ "linkedin",
569
+ "youtube",
570
+ "tiktok",
571
+ "pinterest",
572
+ "email",
573
+ "whatsapp",
574
+ "telegram",
575
+ "discord",
576
+ "snapchat",
577
+ "reddit",
578
+ "github",
579
+ "dribbble",
580
+ "behance",
581
+ "website"
582
+ ]
583
+ },
584
+ "SocialIconStyle": {
585
+ "type": "string",
586
+ "enum": [
587
+ "solid",
588
+ "outlined",
589
+ "rounded",
590
+ "square",
591
+ "circle"
592
+ ]
593
+ },
594
+ "SocialIconSize": {
595
+ "type": "string",
596
+ "enum": [
597
+ "small",
598
+ "medium",
599
+ "large"
600
+ ]
601
+ },
602
+ "SpacerBlock": {
603
+ "type": "object",
604
+ "properties": {
605
+ "id": { "type": "string" },
606
+ "type": {
607
+ "type": "string",
608
+ "const": "spacer"
609
+ },
610
+ "styles": { "$ref": "#/definitions/BlockStyles" },
611
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
612
+ "displayCondition": {
613
+ "type": "object",
614
+ "properties": {
615
+ "label": { "type": "string" },
616
+ "before": { "type": "string" },
617
+ "after": { "type": "string" },
618
+ "group": { "type": "string" },
619
+ "description": { "type": "string" }
620
+ },
621
+ "required": [
622
+ "label",
623
+ "before",
624
+ "after"
625
+ ],
626
+ "additionalProperties": false
627
+ },
628
+ "height": { "type": "number" }
629
+ },
630
+ "required": [
631
+ "height",
632
+ "id",
633
+ "styles",
634
+ "type"
635
+ ],
636
+ "additionalProperties": false
637
+ },
638
+ "HtmlBlock": {
639
+ "type": "object",
640
+ "properties": {
641
+ "id": { "type": "string" },
642
+ "type": {
643
+ "type": "string",
644
+ "const": "html"
645
+ },
646
+ "styles": { "$ref": "#/definitions/BlockStyles" },
647
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
648
+ "displayCondition": {
649
+ "type": "object",
650
+ "properties": {
651
+ "label": { "type": "string" },
652
+ "before": { "type": "string" },
653
+ "after": { "type": "string" },
654
+ "group": { "type": "string" },
655
+ "description": { "type": "string" }
656
+ },
657
+ "required": [
658
+ "label",
659
+ "before",
660
+ "after"
661
+ ],
662
+ "additionalProperties": false
663
+ },
664
+ "content": { "type": "string" }
665
+ },
666
+ "required": [
667
+ "content",
668
+ "id",
669
+ "styles",
670
+ "type"
671
+ ],
672
+ "additionalProperties": false
673
+ },
674
+ "MenuBlock": {
675
+ "type": "object",
676
+ "properties": {
677
+ "id": { "type": "string" },
678
+ "type": {
679
+ "type": "string",
680
+ "const": "menu"
681
+ },
682
+ "styles": { "$ref": "#/definitions/BlockStyles" },
683
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
684
+ "displayCondition": {
685
+ "type": "object",
686
+ "properties": {
687
+ "label": { "type": "string" },
688
+ "before": { "type": "string" },
689
+ "after": { "type": "string" },
690
+ "group": { "type": "string" },
691
+ "description": { "type": "string" }
692
+ },
693
+ "required": [
694
+ "label",
695
+ "before",
696
+ "after"
697
+ ],
698
+ "additionalProperties": false
699
+ },
700
+ "items": {
701
+ "type": "array",
702
+ "items": { "$ref": "#/definitions/MenuItemData" }
703
+ },
704
+ "fontSize": { "type": "number" },
705
+ "fontFamily": { "type": "string" },
706
+ "color": {
707
+ "type": "string",
708
+ "description": "Base text/link color. Unset = inherit the document-level `textColor`."
709
+ },
710
+ "linkColor": { "type": "string" },
711
+ "textAlign": {
712
+ "type": "string",
713
+ "enum": [
714
+ "left",
715
+ "center",
716
+ "right"
717
+ ]
718
+ },
719
+ "separator": { "type": "string" },
720
+ "separatorColor": { "type": "string" },
721
+ "spacing": { "type": "number" }
722
+ },
723
+ "required": [
724
+ "fontSize",
725
+ "id",
726
+ "items",
727
+ "separator",
728
+ "separatorColor",
729
+ "spacing",
730
+ "styles",
731
+ "textAlign",
732
+ "type"
733
+ ],
734
+ "additionalProperties": false
735
+ },
736
+ "MenuItemData": {
737
+ "type": "object",
738
+ "properties": {
739
+ "id": { "type": "string" },
740
+ "text": { "type": "string" },
741
+ "url": { "type": "string" },
742
+ "openInNewTab": { "type": "boolean" },
743
+ "bold": { "type": "boolean" },
744
+ "underline": { "type": "boolean" },
745
+ "color": { "type": "string" }
746
+ },
747
+ "required": [
748
+ "id",
749
+ "text",
750
+ "url",
751
+ "openInNewTab",
752
+ "bold",
753
+ "underline"
754
+ ],
755
+ "additionalProperties": false
756
+ },
757
+ "TableBlock": {
758
+ "type": "object",
759
+ "properties": {
760
+ "id": { "type": "string" },
761
+ "type": {
762
+ "type": "string",
763
+ "const": "table"
764
+ },
765
+ "styles": { "$ref": "#/definitions/BlockStyles" },
766
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
767
+ "displayCondition": {
768
+ "type": "object",
769
+ "properties": {
770
+ "label": { "type": "string" },
771
+ "before": { "type": "string" },
772
+ "after": { "type": "string" },
773
+ "group": { "type": "string" },
774
+ "description": { "type": "string" }
775
+ },
776
+ "required": [
777
+ "label",
778
+ "before",
779
+ "after"
780
+ ],
781
+ "additionalProperties": false
782
+ },
783
+ "rows": {
784
+ "type": "array",
785
+ "items": { "$ref": "#/definitions/TableRowData" }
786
+ },
787
+ "hasHeaderRow": { "type": "boolean" },
788
+ "headerBackgroundColor": { "type": "string" },
789
+ "borderColor": { "type": "string" },
790
+ "borderWidth": { "type": "number" },
791
+ "cellPadding": { "type": "number" },
792
+ "fontSize": { "type": "number" },
793
+ "fontFamily": { "type": "string" },
794
+ "color": {
795
+ "type": "string",
796
+ "description": "Text color. Unset = inherit the document-level `textColor`."
797
+ },
798
+ "textAlign": {
799
+ "type": "string",
800
+ "enum": [
801
+ "left",
802
+ "center",
803
+ "right"
804
+ ]
805
+ }
806
+ },
807
+ "required": [
808
+ "borderColor",
809
+ "borderWidth",
810
+ "cellPadding",
811
+ "fontSize",
812
+ "hasHeaderRow",
813
+ "id",
814
+ "rows",
815
+ "styles",
816
+ "textAlign",
817
+ "type"
818
+ ],
819
+ "additionalProperties": false
820
+ },
821
+ "TableRowData": {
822
+ "type": "object",
823
+ "properties": {
824
+ "id": { "type": "string" },
825
+ "cells": {
826
+ "type": "array",
827
+ "items": { "$ref": "#/definitions/TableCellData" }
828
+ }
829
+ },
830
+ "required": ["id", "cells"],
831
+ "additionalProperties": false
832
+ },
833
+ "TableCellData": {
834
+ "type": "object",
835
+ "properties": {
836
+ "id": { "type": "string" },
837
+ "content": { "type": "string" }
838
+ },
839
+ "required": ["id", "content"],
840
+ "additionalProperties": false
841
+ },
842
+ "CountdownBlock": {
843
+ "type": "object",
844
+ "properties": {
845
+ "id": { "type": "string" },
846
+ "type": {
847
+ "type": "string",
848
+ "const": "countdown"
849
+ },
850
+ "styles": { "$ref": "#/definitions/BlockStyles" },
851
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
852
+ "displayCondition": {
853
+ "type": "object",
854
+ "properties": {
855
+ "label": { "type": "string" },
856
+ "before": { "type": "string" },
857
+ "after": { "type": "string" },
858
+ "group": { "type": "string" },
859
+ "description": { "type": "string" }
860
+ },
861
+ "required": [
862
+ "label",
863
+ "before",
864
+ "after"
865
+ ],
866
+ "additionalProperties": false
867
+ },
868
+ "targetDate": { "type": "string" },
869
+ "timezone": { "type": "string" },
870
+ "showDays": { "type": "boolean" },
871
+ "showHours": { "type": "boolean" },
872
+ "showMinutes": { "type": "boolean" },
873
+ "showSeconds": { "type": "boolean" },
874
+ "separator": {
875
+ "type": "string",
876
+ "enum": [
877
+ ":",
878
+ "-",
879
+ " "
880
+ ]
881
+ },
882
+ "digitFontSize": { "type": "number" },
883
+ "digitColor": { "type": "string" },
884
+ "labelColor": { "type": "string" },
885
+ "labelFontSize": { "type": "number" },
886
+ "backgroundColor": { "type": "string" },
887
+ "fontFamily": { "type": "string" },
888
+ "labelDays": { "type": "string" },
889
+ "labelHours": { "type": "string" },
890
+ "labelMinutes": { "type": "string" },
891
+ "labelSeconds": { "type": "string" },
892
+ "expiredMessage": { "type": "string" },
893
+ "expiredImageUrl": { "type": "string" },
894
+ "hideOnExpiry": { "type": "boolean" }
895
+ },
896
+ "required": [
897
+ "backgroundColor",
898
+ "digitColor",
899
+ "digitFontSize",
900
+ "expiredImageUrl",
901
+ "expiredMessage",
902
+ "hideOnExpiry",
903
+ "id",
904
+ "labelColor",
905
+ "labelDays",
906
+ "labelFontSize",
907
+ "labelHours",
908
+ "labelMinutes",
909
+ "labelSeconds",
910
+ "separator",
911
+ "showDays",
912
+ "showHours",
913
+ "showMinutes",
914
+ "showSeconds",
915
+ "styles",
916
+ "targetDate",
917
+ "timezone",
918
+ "type"
919
+ ],
920
+ "additionalProperties": false
921
+ },
922
+ "CustomBlock": {
923
+ "type": "object",
924
+ "properties": {
925
+ "id": { "type": "string" },
926
+ "type": {
927
+ "type": "string",
928
+ "const": "custom"
929
+ },
930
+ "styles": { "$ref": "#/definitions/BlockStyles" },
931
+ "visibility": { "$ref": "#/definitions/BlockVisibility" },
932
+ "displayCondition": {
933
+ "type": "object",
934
+ "properties": {
935
+ "label": { "type": "string" },
936
+ "before": { "type": "string" },
937
+ "after": { "type": "string" },
938
+ "group": { "type": "string" },
939
+ "description": { "type": "string" }
940
+ },
941
+ "required": [
942
+ "label",
943
+ "before",
944
+ "after"
945
+ ],
946
+ "additionalProperties": false
947
+ },
948
+ "customType": { "type": "string" },
949
+ "fieldValues": {
950
+ "type": "object",
951
+ "additionalProperties": {}
952
+ },
953
+ "renderedHtml": { "type": "string" },
954
+ "dataSourceFetched": { "type": "boolean" }
955
+ },
956
+ "required": [
957
+ "customType",
958
+ "fieldValues",
959
+ "id",
960
+ "styles",
961
+ "type"
962
+ ],
963
+ "additionalProperties": false
964
+ },
965
+ "TemplateSettings": {
966
+ "type": "object",
967
+ "properties": {
968
+ "width": { "type": "number" },
969
+ "backgroundColor": { "type": "string" },
970
+ "textColor": {
971
+ "type": "string",
972
+ "description": "Document-level default text color: the `<mj-text>` default in the rendered MJML, inherited by every text block (Title, Paragraph, Menu, Table) that doesn't set its own `color`. Required, defaulting to `#1a1a1a` (see `DEFAULT_TEMPLATE_DEFAULTS`); customize the default per-consumer via `templateDefaults`. A block's explicit `color` or an inline text-color mark overrides it."
973
+ },
974
+ "linkColor": {
975
+ "type": "string",
976
+ "description": "Document-level link color: emitted as the global `a { color }` rule in the rendered MJML, so it cascades to every link — rich-text and menu alike. Optional: when unset, links inherit the surrounding text color (`color: inherit`), preserving the pre-#352 default. A per-block or per-item color (a Menu item's `color`, `MenuBlock.linkColor`) still overrides it."
977
+ },
978
+ "linkUnderline": {
979
+ "type": "boolean",
980
+ "description": "Whether links are underlined document-wide: drives the global `a { text-decoration }` rule (`underline` when true, `none` when false). Required, defaulting to `true` (see `DEFAULT_TEMPLATE_DEFAULTS`) — the common, more accessible email default. Applies to body (rich-text) links; buttons and menu items carry their own inline `text-decoration` and are unaffected. Set `false` to render links without an underline."
981
+ },
982
+ "fontFamily": { "type": "string" },
983
+ "preheaderText": { "type": "string" },
984
+ "locale": {
985
+ "type": "string",
986
+ "description": "BCP-47 language code for the rendered email's `<html lang>`. Drives screen-reader pronunciation. Default `'en'` via `DEFAULT_TEMPLATE_DEFAULTS`."
987
+ },
988
+ "direction": {
989
+ "$ref": "#/definitions/ContentDirection",
990
+ "description": "Writing direction of the delivered email: the canvas `dir` and the rendered `<mjml dir>`. Independent of the editor chrome (`init({ locale })`). Optional: when unset, `resolveContentDirection` falls through to the content language (`ar` / `he` / `fa` / … → `\"rtl\"`, otherwise `\"ltr\"`). An explicit `\"ltr\"` on an Arabic template is a stated decision, not a missing value."
991
+ }
992
+ },
993
+ "required": [
994
+ "width",
995
+ "backgroundColor",
996
+ "textColor",
997
+ "linkUnderline",
998
+ "fontFamily",
999
+ "locale"
1000
+ ],
1001
+ "additionalProperties": false
1002
+ },
1003
+ "ContentDirection": {
1004
+ "type": "string",
1005
+ "enum": ["ltr", "rtl"],
1006
+ "description": "Writing direction of the delivered email — the canvas `dir` and the rendered `<mjml dir>`. Independent of the editor chrome language (`init({ locale })`)."
1007
+ }
1008
+ }
1009
+ };
1010
+ const ajv = new Ajv({
1011
+ allErrors: true,
1012
+ strict: false
1013
+ });
1014
+ const typeToDef = {};
1015
+ for (const [name, def] of Object.entries(schema.definitions)) {
1016
+ const constType = def?.properties?.type?.const;
1017
+ if (typeof constType === "string") typeToDef[constType] = name;
1018
+ }
1019
+ const validatorCache = /* @__PURE__ */ new Map();
1020
+ function validatorFor(defName) {
1021
+ const cached = validatorCache.get(defName);
1022
+ if (cached) return cached;
1023
+ const definitions = structuredClone(schema.definitions);
1024
+ if (defName === "SectionBlock") {
1025
+ const section = definitions.SectionBlock;
1026
+ if (section?.properties) section.properties.children = { type: "array" };
1027
+ }
1028
+ const compiled = ajv.compile({
1029
+ $ref: `#/definitions/${defName}`,
1030
+ definitions
1031
+ });
1032
+ validatorCache.set(defName, compiled);
1033
+ return compiled;
1034
+ }
1035
+ const settingsValidator = ajv.compile({
1036
+ $ref: "#/definitions/TemplateSettings",
1037
+ definitions: structuredClone(schema.definitions)
1038
+ });
1039
+ function flattenBlocks(blocks, basePath, out) {
1040
+ if (!Array.isArray(blocks)) return;
1041
+ blocks.forEach((block, i) => {
1042
+ const path = `${basePath}[${i}]`;
1043
+ out.push({
1044
+ path,
1045
+ block: block ?? null
1046
+ });
1047
+ if (block?.type === "section" && Array.isArray(block.children)) block.children.forEach((column, ci) => {
1048
+ flattenBlocks(column, `${path}.children[${ci}]`, out);
1049
+ });
1050
+ });
1051
+ }
1052
+ function formatAjvErrors(errors, prefix) {
1053
+ return (errors ?? []).map((e) => {
1054
+ const where = `${prefix}${e.instancePath}`;
1055
+ const extra = e.keyword === "additionalProperties" ? ` (${e.params.additionalProperty})` : "";
1056
+ return `${where} ${e.message}${extra}`;
1057
+ });
1058
+ }
1059
+ /**
1060
+ * Structural validation. Synchronous, depends only on ajv + the committed
1061
+ * schema.json (no build of the workspace packages required).
1062
+ */
1063
+ function validateTemplate(data) {
1064
+ const errors = [];
1065
+ if (data === null || typeof data !== "object" || Array.isArray(data)) return {
1066
+ valid: false,
1067
+ errors: ["(root) must be an object"]
1068
+ };
1069
+ const doc = data;
1070
+ if (!Array.isArray(doc.blocks)) errors.push("blocks must be an array");
1071
+ if (doc.settings === null || typeof doc.settings !== "object") errors.push("settings must be an object");
1072
+ else if (!settingsValidator(doc.settings)) errors.push(...formatAjvErrors(settingsValidator.errors, "settings"));
1073
+ const flat = [];
1074
+ flattenBlocks(doc.blocks, "blocks", flat);
1075
+ for (const { path, block } of flat) {
1076
+ const type = block?.type;
1077
+ const defName = typeof type === "string" ? typeToDef[type] : void 0;
1078
+ if (!defName) {
1079
+ const known = Object.keys(typeToDef).join(", ");
1080
+ errors.push(`${path} has unknown or missing block type ${JSON.stringify(type)} (expected one of: ${known})`);
1081
+ continue;
1082
+ }
1083
+ const validate = validatorFor(defName);
1084
+ if (!validate(block)) errors.push(...formatAjvErrors(validate.errors, `${path} (${type})`));
1085
+ }
1086
+ return {
1087
+ valid: errors.length === 0,
1088
+ errors
1089
+ };
1090
+ }
1091
+ /**
1092
+ * Quality layer — accessibility / structure / link linting.
1093
+ *
1094
+ * Assumes structurally-valid input, so callers should run `validateTemplate`
1095
+ * first; the try/catch is a guard against a malformed template crashing the
1096
+ * linter rather than a substitute for that ordering.
1097
+ */
1098
+ function runQualityLint(data) {
1099
+ try {
1100
+ return { issues: lintTemplate(data) ?? [] };
1101
+ } catch (err) {
1102
+ return {
1103
+ issues: [],
1104
+ error: err.message
1105
+ };
1106
+ }
1107
+ }
1108
+ //#endregion
1109
+ //#region src/operations.ts
1110
+ /** Columns a layout declares. Mirrors the helper in core's editor. */
1111
+ function getColumnCount(layout) {
1112
+ if (layout === "1") return 1;
1113
+ if (layout === "3") return 3;
1114
+ return 2;
1115
+ }
1116
+ function findBlockById(blocks, id) {
1117
+ for (const block of blocks) {
1118
+ if (block.id === id) return block;
1119
+ if (block.type === "section") for (const column of block.children) {
1120
+ const found = findBlockById(column, id);
1121
+ if (found) return found;
1122
+ }
1123
+ }
1124
+ return null;
1125
+ }
1126
+ function findBlockParent(blocks, id, parent = { blocks }) {
1127
+ for (const block of blocks) {
1128
+ if (block.id === id) return parent;
1129
+ if (block.type === "section") for (let colIdx = 0; colIdx < block.children.length; colIdx++) {
1130
+ const result = findBlockParent(block.children[colIdx], id, {
1131
+ blocks: block.children[colIdx],
1132
+ sectionId: block.id,
1133
+ columnIndex: colIdx
1134
+ });
1135
+ if (result) return result;
1136
+ }
1137
+ }
1138
+ return null;
1139
+ }
1140
+ function reject(content, error) {
1141
+ return {
1142
+ ok: false,
1143
+ content,
1144
+ error
1145
+ };
1146
+ }
1147
+ /**
1148
+ * Resolve the array an operation targets, enforcing the column rules.
1149
+ * Returns a string on rejection so callers can surface the reason.
1150
+ */
1151
+ function resolveTarget(content, targetSectionId, columnIndex) {
1152
+ if (!targetSectionId) return content.blocks;
1153
+ const section = findBlockById(content.blocks, targetSectionId);
1154
+ if (!section) return `No block with id "${targetSectionId}".`;
1155
+ if (section.type !== "section") return `Block "${targetSectionId}" is a ${section.type}, not a section — it has no columns.`;
1156
+ const count = getColumnCount(section.columns);
1157
+ if (columnIndex < 0 || columnIndex >= count) return `Column ${columnIndex} is out of range for a "${section.columns}" section (${count} column(s)).`;
1158
+ const children = section;
1159
+ children.children[columnIndex] = children.children[columnIndex] || [];
1160
+ return children.children[columnIndex];
1161
+ }
1162
+ function insertAt(target, block, index) {
1163
+ if (index !== void 0 && index < target.length) target.splice(index, 0, block);
1164
+ else target.push(block);
1165
+ }
1166
+ /** Apply one operation, returning a new document. Never mutates the input. */
1167
+ function applyOperation(content, payload) {
1168
+ const data = payload.data ?? {};
1169
+ switch (payload.operation) {
1170
+ case "setContent": {
1171
+ const next = data.content;
1172
+ if (!next || typeof next !== "object" || !Array.isArray(next.blocks)) return reject(content, "setContent needs `content` with a blocks array.");
1173
+ return {
1174
+ ok: true,
1175
+ content: safeClone(next)
1176
+ };
1177
+ }
1178
+ case "updateSettings": {
1179
+ const updates = data.settings;
1180
+ if (!updates || typeof updates !== "object") return reject(content, "updateSettings needs a `settings` object.");
1181
+ const draft = safeClone(content);
1182
+ draft.settings = {
1183
+ ...draft.settings,
1184
+ ...updates
1185
+ };
1186
+ return {
1187
+ ok: true,
1188
+ content: draft
1189
+ };
1190
+ }
1191
+ case "addBlock": {
1192
+ const block = data.block;
1193
+ if (!block || typeof block !== "object" || typeof block.id !== "string") return reject(content, "addBlock needs a `block` with an id.");
1194
+ const targetSectionId = data.targetSectionId;
1195
+ if (targetSectionId && block.type === "section") return reject(content, "A section cannot be nested inside a section column — MJML would drop it on export. Add it at the top level instead.");
1196
+ const draft = safeClone(content);
1197
+ if (findBlockById(draft.blocks, block.id)) return reject(content, `A block with id "${block.id}" already exists.`);
1198
+ const target = resolveTarget(draft, targetSectionId, data.columnIndex ?? 0);
1199
+ if (typeof target === "string") return reject(content, target);
1200
+ insertAt(target, safeClone(block), data.index);
1201
+ return {
1202
+ ok: true,
1203
+ content: draft
1204
+ };
1205
+ }
1206
+ case "updateBlock": {
1207
+ const blockId = data.blockId;
1208
+ const updates = data.updates;
1209
+ if (!blockId) return reject(content, "updateBlock needs a `blockId`.");
1210
+ if (!updates || typeof updates !== "object") return reject(content, "updateBlock needs an `updates` object.");
1211
+ const draft = safeClone(content);
1212
+ const block = findBlockById(draft.blocks, blockId);
1213
+ if (!block) return reject(content, `No block with id "${blockId}".`);
1214
+ if ("type" in updates && updates.type !== block.type) return reject(content, `Cannot change a block's type (${block.type} → ${String(updates.type)}). Delete it and add the new block instead.`);
1215
+ Object.assign(block, updates);
1216
+ return {
1217
+ ok: true,
1218
+ content: draft
1219
+ };
1220
+ }
1221
+ case "updateBlockStyle": {
1222
+ const blockId = data.blockId;
1223
+ const styles = data.styles;
1224
+ if (!blockId) return reject(content, "updateBlockStyle needs a `blockId`.");
1225
+ if (!styles || typeof styles !== "object") return reject(content, "updateBlockStyle needs a `styles` object.");
1226
+ const draft = safeClone(content);
1227
+ const block = findBlockById(draft.blocks, blockId);
1228
+ if (!block) return reject(content, `No block with id "${blockId}".`);
1229
+ block.styles = {
1230
+ ...block.styles,
1231
+ ...styles
1232
+ };
1233
+ return {
1234
+ ok: true,
1235
+ content: draft
1236
+ };
1237
+ }
1238
+ case "deleteBlock": {
1239
+ const blockId = data.blockId;
1240
+ if (!blockId) return reject(content, "deleteBlock needs a `blockId`.");
1241
+ const draft = safeClone(content);
1242
+ const parent = findBlockParent(draft.blocks, blockId);
1243
+ if (!parent) return reject(content, `No block with id "${blockId}".`);
1244
+ const index = parent.blocks.findIndex((b) => b.id === blockId);
1245
+ parent.blocks.splice(index, 1);
1246
+ return {
1247
+ ok: true,
1248
+ content: draft
1249
+ };
1250
+ }
1251
+ case "moveBlock": {
1252
+ const blockId = data.blockId;
1253
+ const index = data.index;
1254
+ if (!blockId) return reject(content, "moveBlock needs a `blockId`.");
1255
+ if (typeof index !== "number" || index < 0) return reject(content, "moveBlock needs a non-negative `index`.");
1256
+ const targetSectionId = data.targetSectionId;
1257
+ const draft = safeClone(content);
1258
+ const parent = findBlockParent(draft.blocks, blockId);
1259
+ if (!parent) return reject(content, `No block with id "${blockId}".`);
1260
+ const oldIndex = parent.blocks.findIndex((b) => b.id === blockId);
1261
+ if (targetSectionId && parent.blocks[oldIndex].type === "section") return reject(content, "A section cannot be moved into a section column — MJML would drop it on export.");
1262
+ if (targetSectionId === blockId) return reject(content, "A block cannot be moved into itself.");
1263
+ const target = resolveTarget(draft, targetSectionId, data.columnIndex ?? 0);
1264
+ if (typeof target === "string") return reject(content, target);
1265
+ const [block] = parent.blocks.splice(oldIndex, 1);
1266
+ target.splice(index, 0, block);
1267
+ return {
1268
+ ok: true,
1269
+ content: draft
1270
+ };
1271
+ }
1272
+ default: return reject(content, `Unknown operation "${String(payload.operation)}".`);
1273
+ }
1274
+ }
1275
+ //#endregion
1276
+ export { validateTemplate as a, schema as i, getColumnCount as n, runQualityLint as r, applyOperation as t };
1277
+
1278
+ //# sourceMappingURL=src-unNYVMlC.js.map