@templatical/template-tools 0.46.0 → 0.48.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.
- package/README.md +15 -3
- package/custom-block-schema.json +459 -0
- package/dist/bin.js +164 -17
- package/dist/bin.js.map +1 -1
- package/dist/custom-block-CsZN9qMM.js +2670 -0
- package/dist/custom-block-CsZN9qMM.js.map +1 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.js +3 -1
- package/dist/live/index.d.ts +48 -7
- package/dist/live/index.js +210 -9
- package/dist/live/index.js.map +1 -1
- package/dist/src-SZZkttBC.js +172 -0
- package/dist/src-SZZkttBC.js.map +1 -0
- package/live/index.html +64 -0
- package/package.json +20 -16
- package/schema.json +135 -1
- package/dist/src-De5svMDM.js +0 -1462
- package/dist/src-De5svMDM.js.map +0 -1
|
@@ -0,0 +1,2670 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
import { getContrastRatio, isOpaqueHex, lintAccessibility, lintTemplate } from "@templatical/quality";
|
|
3
|
+
import { createCustomBlock, createDefaultTemplateContent, createParagraphBlock } from "@templatical/types";
|
|
4
|
+
import { Parser } from "htmlparser2";
|
|
5
|
+
import { ForTag, Liquid, Output, Tag } from "liquidjs";
|
|
6
|
+
import { escapeHtml, renderToMjml } from "@templatical/renderer";
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/validate.ts
|
|
9
|
+
/** The generated JSON Schema for `TemplateContent`, as a plain object. */
|
|
10
|
+
const schema = {
|
|
11
|
+
$id: "https://templatical.com/schema/template-content.json",
|
|
12
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
13
|
+
$ref: "#/definitions/TemplateContent",
|
|
14
|
+
definitions: {
|
|
15
|
+
"TemplateContent": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"blocks": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": { "$ref": "#/definitions/Block" }
|
|
21
|
+
},
|
|
22
|
+
"settings": { "$ref": "#/definitions/TemplateSettings" }
|
|
23
|
+
},
|
|
24
|
+
"required": ["blocks", "settings"],
|
|
25
|
+
"additionalProperties": false
|
|
26
|
+
},
|
|
27
|
+
"Block": { "anyOf": [
|
|
28
|
+
{ "$ref": "#/definitions/SectionBlock" },
|
|
29
|
+
{ "$ref": "#/definitions/TitleBlock" },
|
|
30
|
+
{ "$ref": "#/definitions/ParagraphBlock" },
|
|
31
|
+
{ "$ref": "#/definitions/ImageBlock" },
|
|
32
|
+
{ "$ref": "#/definitions/ButtonBlock" },
|
|
33
|
+
{ "$ref": "#/definitions/DividerBlock" },
|
|
34
|
+
{ "$ref": "#/definitions/VideoBlock" },
|
|
35
|
+
{ "$ref": "#/definitions/SocialIconsBlock" },
|
|
36
|
+
{ "$ref": "#/definitions/SpacerBlock" },
|
|
37
|
+
{ "$ref": "#/definitions/HtmlBlock" },
|
|
38
|
+
{ "$ref": "#/definitions/MenuBlock" },
|
|
39
|
+
{ "$ref": "#/definitions/TableBlock" },
|
|
40
|
+
{ "$ref": "#/definitions/CountdownBlock" },
|
|
41
|
+
{ "$ref": "#/definitions/CustomBlock" },
|
|
42
|
+
{ "$ref": "#/definitions/SlotBlock" },
|
|
43
|
+
{ "$ref": "#/definitions/WrapperBlock" }
|
|
44
|
+
] },
|
|
45
|
+
"SectionBlock": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"properties": {
|
|
48
|
+
"id": { "type": "string" },
|
|
49
|
+
"type": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"const": "section"
|
|
52
|
+
},
|
|
53
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
54
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
55
|
+
"displayCondition": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"label": { "type": "string" },
|
|
59
|
+
"before": { "type": "string" },
|
|
60
|
+
"after": { "type": "string" },
|
|
61
|
+
"group": { "type": "string" },
|
|
62
|
+
"description": { "type": "string" }
|
|
63
|
+
},
|
|
64
|
+
"required": [
|
|
65
|
+
"label",
|
|
66
|
+
"before",
|
|
67
|
+
"after"
|
|
68
|
+
],
|
|
69
|
+
"additionalProperties": false
|
|
70
|
+
},
|
|
71
|
+
"columns": { "$ref": "#/definitions/ColumnLayout" },
|
|
72
|
+
"children": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"items": {
|
|
75
|
+
"type": "array",
|
|
76
|
+
"items": { "$ref": "#/definitions/Block" }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"stackOnMobile": {
|
|
80
|
+
"type": "boolean",
|
|
81
|
+
"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."
|
|
82
|
+
},
|
|
83
|
+
"borderRadius": {
|
|
84
|
+
"$ref": "#/definitions/BorderRadiusValue",
|
|
85
|
+
"description": "Corner radius in px — one number, or a radius per corner. Omitted/0 = square corners."
|
|
86
|
+
},
|
|
87
|
+
"border": {
|
|
88
|
+
"$ref": "#/definitions/BorderValue",
|
|
89
|
+
"description": "Border around the section box, per side. Omitted = no border; a side with width 0 is not drawn."
|
|
90
|
+
},
|
|
91
|
+
"backgroundImage": {
|
|
92
|
+
"$ref": "#/definitions/BackgroundImage",
|
|
93
|
+
"description": "Image behind the section's columns, rendered as `background-url` (plus size, position and repeat) on the `mj-section`. `styles.backgroundColor` is its fallback."
|
|
94
|
+
},
|
|
95
|
+
"hero": {
|
|
96
|
+
"$ref": "#/definitions/SectionHero",
|
|
97
|
+
"description": "Draws a one-column section with a background image as an `mj-hero`: the image fills the box and the content sits on top. Ignored (the section renders as a regular one, and the `structure` linter says why) with more than one column, without an image, or with `height: \"image\"` and no image size."
|
|
98
|
+
},
|
|
99
|
+
"wrapper": {
|
|
100
|
+
"$ref": "#/definitions/SectionWrapper",
|
|
101
|
+
"description": "Optional outer frame (rendered as an `mj-wrapper` around the section)."
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"required": [
|
|
105
|
+
"children",
|
|
106
|
+
"columns",
|
|
107
|
+
"id",
|
|
108
|
+
"styles",
|
|
109
|
+
"type"
|
|
110
|
+
],
|
|
111
|
+
"additionalProperties": false
|
|
112
|
+
},
|
|
113
|
+
"BlockStyles": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"padding": { "$ref": "#/definitions/SpacingValue" },
|
|
117
|
+
"backgroundColor": { "type": "string" }
|
|
118
|
+
},
|
|
119
|
+
"required": ["padding"],
|
|
120
|
+
"additionalProperties": false
|
|
121
|
+
},
|
|
122
|
+
"SpacingValue": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": {
|
|
125
|
+
"top": { "type": "number" },
|
|
126
|
+
"right": { "type": "number" },
|
|
127
|
+
"bottom": { "type": "number" },
|
|
128
|
+
"left": { "type": "number" }
|
|
129
|
+
},
|
|
130
|
+
"required": [
|
|
131
|
+
"top",
|
|
132
|
+
"right",
|
|
133
|
+
"bottom",
|
|
134
|
+
"left"
|
|
135
|
+
],
|
|
136
|
+
"additionalProperties": false
|
|
137
|
+
},
|
|
138
|
+
"BlockVisibility": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"desktop": { "type": "boolean" },
|
|
142
|
+
"mobile": { "type": "boolean" }
|
|
143
|
+
},
|
|
144
|
+
"required": ["desktop", "mobile"],
|
|
145
|
+
"additionalProperties": false
|
|
146
|
+
},
|
|
147
|
+
"ColumnLayout": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"enum": [
|
|
150
|
+
"1",
|
|
151
|
+
"2",
|
|
152
|
+
"3",
|
|
153
|
+
"2-1",
|
|
154
|
+
"1-2"
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
"BorderRadiusValue": {
|
|
158
|
+
"anyOf": [{ "type": "number" }, { "$ref": "#/definitions/CornerRadius" }],
|
|
159
|
+
"description": "A corner radius in px: one number for all four corners, or a radius per corner."
|
|
160
|
+
},
|
|
161
|
+
"CornerRadius": {
|
|
162
|
+
"type": "object",
|
|
163
|
+
"properties": {
|
|
164
|
+
"topLeft": { "type": "number" },
|
|
165
|
+
"topRight": { "type": "number" },
|
|
166
|
+
"bottomRight": { "type": "number" },
|
|
167
|
+
"bottomLeft": { "type": "number" }
|
|
168
|
+
},
|
|
169
|
+
"required": [
|
|
170
|
+
"topLeft",
|
|
171
|
+
"topRight",
|
|
172
|
+
"bottomRight",
|
|
173
|
+
"bottomLeft"
|
|
174
|
+
],
|
|
175
|
+
"additionalProperties": false,
|
|
176
|
+
"description": "A radius per corner, in px."
|
|
177
|
+
},
|
|
178
|
+
"BorderValue": {
|
|
179
|
+
"type": "object",
|
|
180
|
+
"properties": {
|
|
181
|
+
"top": { "$ref": "#/definitions/BorderSideValue" },
|
|
182
|
+
"right": { "$ref": "#/definitions/BorderSideValue" },
|
|
183
|
+
"bottom": { "$ref": "#/definitions/BorderSideValue" },
|
|
184
|
+
"left": { "$ref": "#/definitions/BorderSideValue" }
|
|
185
|
+
},
|
|
186
|
+
"required": [
|
|
187
|
+
"top",
|
|
188
|
+
"right",
|
|
189
|
+
"bottom",
|
|
190
|
+
"left"
|
|
191
|
+
],
|
|
192
|
+
"additionalProperties": false,
|
|
193
|
+
"description": "A border described per side, like `SpacingValue`. Only elements MJML can border natively carry one: sections, images and buttons."
|
|
194
|
+
},
|
|
195
|
+
"BorderSideValue": {
|
|
196
|
+
"type": "object",
|
|
197
|
+
"properties": {
|
|
198
|
+
"width": {
|
|
199
|
+
"type": "number",
|
|
200
|
+
"description": "Width in px. `0` means no border on this side."
|
|
201
|
+
},
|
|
202
|
+
"style": { "$ref": "#/definitions/BorderStyle" },
|
|
203
|
+
"color": { "type": "string" }
|
|
204
|
+
},
|
|
205
|
+
"required": [
|
|
206
|
+
"width",
|
|
207
|
+
"style",
|
|
208
|
+
"color"
|
|
209
|
+
],
|
|
210
|
+
"additionalProperties": false,
|
|
211
|
+
"description": "One side of a border. A width of `0` leaves that side undrawn."
|
|
212
|
+
},
|
|
213
|
+
"BorderStyle": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"enum": [
|
|
216
|
+
"solid",
|
|
217
|
+
"dashed",
|
|
218
|
+
"dotted"
|
|
219
|
+
]
|
|
220
|
+
},
|
|
221
|
+
"BackgroundImage": {
|
|
222
|
+
"type": "object",
|
|
223
|
+
"properties": {
|
|
224
|
+
"url": { "type": "string" },
|
|
225
|
+
"size": { "$ref": "#/definitions/BackgroundSize" },
|
|
226
|
+
"position": {
|
|
227
|
+
"type": "object",
|
|
228
|
+
"properties": {
|
|
229
|
+
"x": { "$ref": "#/definitions/BackgroundPositionX" },
|
|
230
|
+
"y": { "$ref": "#/definitions/BackgroundPositionY" }
|
|
231
|
+
},
|
|
232
|
+
"required": ["x", "y"],
|
|
233
|
+
"additionalProperties": false
|
|
234
|
+
},
|
|
235
|
+
"repeat": {
|
|
236
|
+
"type": "boolean",
|
|
237
|
+
"description": "`false` paints the image once; `true` tiles it."
|
|
238
|
+
},
|
|
239
|
+
"naturalWidth": {
|
|
240
|
+
"type": "number",
|
|
241
|
+
"description": "The image file's own width in px, when known: given by the media library, measured by the editor when the author sets the URL, or read by an importer (from MJML, the hero's `background-width`, which may be the box's size rather than the file's). Only the ratio with `naturalHeight` is used: a section hero with `height: \"image\"` keeps its box at that shape. Both or neither; one without the other reads as unknown."
|
|
242
|
+
},
|
|
243
|
+
"naturalHeight": {
|
|
244
|
+
"type": "number",
|
|
245
|
+
"description": "The image file's own height in px, when known; it pairs with `naturalWidth`, and only their ratio is used. Both or neither."
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"required": [
|
|
249
|
+
"url",
|
|
250
|
+
"size",
|
|
251
|
+
"position",
|
|
252
|
+
"repeat"
|
|
253
|
+
],
|
|
254
|
+
"additionalProperties": false,
|
|
255
|
+
"description": "An image painted behind a section or a wrapper band. The element's own `backgroundColor` is its fallback: it shows while the image loads, with images off, and in clients without background-image support."
|
|
256
|
+
},
|
|
257
|
+
"BackgroundSize": {
|
|
258
|
+
"type": "string",
|
|
259
|
+
"enum": [
|
|
260
|
+
"cover",
|
|
261
|
+
"contain",
|
|
262
|
+
"auto"
|
|
263
|
+
],
|
|
264
|
+
"description": "`background-size` keywords. In Outlook on Windows (VML), `cover` and `contain` draw a single frame when `repeat` is off and tile when it is on; `auto` always tiles from the top center, whatever `position` and `repeat` say."
|
|
265
|
+
},
|
|
266
|
+
"BackgroundPositionX": {
|
|
267
|
+
"type": "string",
|
|
268
|
+
"enum": [
|
|
269
|
+
"left",
|
|
270
|
+
"center",
|
|
271
|
+
"right"
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
"BackgroundPositionY": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"enum": [
|
|
277
|
+
"top",
|
|
278
|
+
"center",
|
|
279
|
+
"bottom"
|
|
280
|
+
]
|
|
281
|
+
},
|
|
282
|
+
"SectionHero": {
|
|
283
|
+
"type": "object",
|
|
284
|
+
"properties": {
|
|
285
|
+
"height": {
|
|
286
|
+
"anyOf": [{
|
|
287
|
+
"type": "string",
|
|
288
|
+
"const": "image"
|
|
289
|
+
}, { "type": "number" }],
|
|
290
|
+
"description": "`\"image\"` makes the box as tall as the image drawn across its desktop width, and needs the image's size on `backgroundImage.naturalWidth` / `naturalHeight`; a phone keeps that height and crops the image's sides. A number is the box's fixed height in px, padding included."
|
|
291
|
+
},
|
|
292
|
+
"verticalAlign": { "$ref": "#/definitions/HeroVerticalAlign" }
|
|
293
|
+
},
|
|
294
|
+
"required": ["height", "verticalAlign"],
|
|
295
|
+
"additionalProperties": false,
|
|
296
|
+
"description": "Draws a one-column section as an MJML `mj-hero`: its background image fills the box (always `cover`, never tiled) and the content sits on top of it. A hero applies only to a one-column section with a background image; otherwise the section renders as a regular one."
|
|
297
|
+
},
|
|
298
|
+
"HeroVerticalAlign": {
|
|
299
|
+
"type": "string",
|
|
300
|
+
"enum": [
|
|
301
|
+
"top",
|
|
302
|
+
"middle",
|
|
303
|
+
"bottom"
|
|
304
|
+
],
|
|
305
|
+
"description": "Where a hero's content sits in its box."
|
|
306
|
+
},
|
|
307
|
+
"SectionWrapper": {
|
|
308
|
+
"type": "object",
|
|
309
|
+
"properties": {
|
|
310
|
+
"backgroundColor": { "type": "string" },
|
|
311
|
+
"padding": { "$ref": "#/definitions/SpacingValue" },
|
|
312
|
+
"borderRadius": {
|
|
313
|
+
"$ref": "#/definitions/BorderRadiusValue",
|
|
314
|
+
"description": "Corner radius in px for the outer frame — one number, or a radius per corner. Omitted/0 = square corners."
|
|
315
|
+
},
|
|
316
|
+
"backgroundImage": {
|
|
317
|
+
"$ref": "#/definitions/BackgroundImage",
|
|
318
|
+
"description": "Image behind the band; `backgroundColor` is its fallback."
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
"additionalProperties": false,
|
|
322
|
+
"description": "Optional outer frame for a section. When present, the section is rendered inside an `mj-wrapper` — a band (its own background + padding) that frames the section, e.g. a white card sitting on a colored band. The band is as wide as the email body (`settings.width`), not the recipient's viewport."
|
|
323
|
+
},
|
|
324
|
+
"TitleBlock": {
|
|
325
|
+
"type": "object",
|
|
326
|
+
"properties": {
|
|
327
|
+
"id": { "type": "string" },
|
|
328
|
+
"type": {
|
|
329
|
+
"type": "string",
|
|
330
|
+
"const": "title"
|
|
331
|
+
},
|
|
332
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
333
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
334
|
+
"displayCondition": {
|
|
335
|
+
"type": "object",
|
|
336
|
+
"properties": {
|
|
337
|
+
"label": { "type": "string" },
|
|
338
|
+
"before": { "type": "string" },
|
|
339
|
+
"after": { "type": "string" },
|
|
340
|
+
"group": { "type": "string" },
|
|
341
|
+
"description": { "type": "string" }
|
|
342
|
+
},
|
|
343
|
+
"required": [
|
|
344
|
+
"label",
|
|
345
|
+
"before",
|
|
346
|
+
"after"
|
|
347
|
+
],
|
|
348
|
+
"additionalProperties": false
|
|
349
|
+
},
|
|
350
|
+
"content": { "type": "string" },
|
|
351
|
+
"level": { "$ref": "#/definitions/HeadingLevel" },
|
|
352
|
+
"color": {
|
|
353
|
+
"type": "string",
|
|
354
|
+
"description": "Text color. Unset = inherit the document-level `textColor`."
|
|
355
|
+
},
|
|
356
|
+
"textAlign": {
|
|
357
|
+
"type": "string",
|
|
358
|
+
"enum": [
|
|
359
|
+
"left",
|
|
360
|
+
"center",
|
|
361
|
+
"right"
|
|
362
|
+
]
|
|
363
|
+
},
|
|
364
|
+
"fontFamily": { "type": "string" }
|
|
365
|
+
},
|
|
366
|
+
"required": [
|
|
367
|
+
"content",
|
|
368
|
+
"id",
|
|
369
|
+
"level",
|
|
370
|
+
"styles",
|
|
371
|
+
"textAlign",
|
|
372
|
+
"type"
|
|
373
|
+
],
|
|
374
|
+
"additionalProperties": false
|
|
375
|
+
},
|
|
376
|
+
"HeadingLevel": {
|
|
377
|
+
"type": "number",
|
|
378
|
+
"enum": [
|
|
379
|
+
1,
|
|
380
|
+
2,
|
|
381
|
+
3,
|
|
382
|
+
4
|
|
383
|
+
]
|
|
384
|
+
},
|
|
385
|
+
"ParagraphBlock": {
|
|
386
|
+
"type": "object",
|
|
387
|
+
"properties": {
|
|
388
|
+
"id": { "type": "string" },
|
|
389
|
+
"type": {
|
|
390
|
+
"type": "string",
|
|
391
|
+
"const": "paragraph"
|
|
392
|
+
},
|
|
393
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
394
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
395
|
+
"displayCondition": {
|
|
396
|
+
"type": "object",
|
|
397
|
+
"properties": {
|
|
398
|
+
"label": { "type": "string" },
|
|
399
|
+
"before": { "type": "string" },
|
|
400
|
+
"after": { "type": "string" },
|
|
401
|
+
"group": { "type": "string" },
|
|
402
|
+
"description": { "type": "string" }
|
|
403
|
+
},
|
|
404
|
+
"required": [
|
|
405
|
+
"label",
|
|
406
|
+
"before",
|
|
407
|
+
"after"
|
|
408
|
+
],
|
|
409
|
+
"additionalProperties": false
|
|
410
|
+
},
|
|
411
|
+
"content": { "type": "string" },
|
|
412
|
+
"paragraphSpacing": {
|
|
413
|
+
"type": "number",
|
|
414
|
+
"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."
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
"required": [
|
|
418
|
+
"content",
|
|
419
|
+
"id",
|
|
420
|
+
"styles",
|
|
421
|
+
"type"
|
|
422
|
+
],
|
|
423
|
+
"additionalProperties": false
|
|
424
|
+
},
|
|
425
|
+
"ImageBlock": {
|
|
426
|
+
"type": "object",
|
|
427
|
+
"properties": {
|
|
428
|
+
"id": { "type": "string" },
|
|
429
|
+
"type": {
|
|
430
|
+
"type": "string",
|
|
431
|
+
"const": "image"
|
|
432
|
+
},
|
|
433
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
434
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
435
|
+
"displayCondition": {
|
|
436
|
+
"type": "object",
|
|
437
|
+
"properties": {
|
|
438
|
+
"label": { "type": "string" },
|
|
439
|
+
"before": { "type": "string" },
|
|
440
|
+
"after": { "type": "string" },
|
|
441
|
+
"group": { "type": "string" },
|
|
442
|
+
"description": { "type": "string" }
|
|
443
|
+
},
|
|
444
|
+
"required": [
|
|
445
|
+
"label",
|
|
446
|
+
"before",
|
|
447
|
+
"after"
|
|
448
|
+
],
|
|
449
|
+
"additionalProperties": false
|
|
450
|
+
},
|
|
451
|
+
"src": { "type": "string" },
|
|
452
|
+
"alt": { "type": "string" },
|
|
453
|
+
"width": { "anyOf": [{ "type": "number" }, {
|
|
454
|
+
"type": "string",
|
|
455
|
+
"const": "full"
|
|
456
|
+
}] },
|
|
457
|
+
"height": {
|
|
458
|
+
"type": "number",
|
|
459
|
+
"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`."
|
|
460
|
+
},
|
|
461
|
+
"naturalWidth": {
|
|
462
|
+
"type": "number",
|
|
463
|
+
"description": "The image file's own size in px, when known: given by the media library, read by an importer, or measured in the editor during an edit. Both or neither; one without the other reads as unknown. It drives the editor's aspect-ratio lock and is never rendered. Cleared when `src` changes without a new size alongside it."
|
|
464
|
+
},
|
|
465
|
+
"naturalHeight": { "type": "number" },
|
|
466
|
+
"align": {
|
|
467
|
+
"type": "string",
|
|
468
|
+
"enum": [
|
|
469
|
+
"left",
|
|
470
|
+
"center",
|
|
471
|
+
"right"
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
"borderRadius": {
|
|
475
|
+
"$ref": "#/definitions/BorderRadiusValue",
|
|
476
|
+
"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. A radius per corner is also accepted."
|
|
477
|
+
},
|
|
478
|
+
"border": {
|
|
479
|
+
"$ref": "#/definitions/BorderValue",
|
|
480
|
+
"description": "Border around the image itself, per side. Omitted = no border; a side with width 0 is not drawn."
|
|
481
|
+
},
|
|
482
|
+
"linkUrl": { "type": "string" },
|
|
483
|
+
"linkOpenInNewTab": { "type": "boolean" },
|
|
484
|
+
"placeholderUrl": { "type": "string" },
|
|
485
|
+
"decorative": { "type": "boolean" }
|
|
486
|
+
},
|
|
487
|
+
"required": [
|
|
488
|
+
"align",
|
|
489
|
+
"alt",
|
|
490
|
+
"id",
|
|
491
|
+
"src",
|
|
492
|
+
"styles",
|
|
493
|
+
"type",
|
|
494
|
+
"width"
|
|
495
|
+
],
|
|
496
|
+
"additionalProperties": false
|
|
497
|
+
},
|
|
498
|
+
"ButtonBlock": {
|
|
499
|
+
"type": "object",
|
|
500
|
+
"properties": {
|
|
501
|
+
"id": { "type": "string" },
|
|
502
|
+
"type": {
|
|
503
|
+
"type": "string",
|
|
504
|
+
"const": "button"
|
|
505
|
+
},
|
|
506
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
507
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
508
|
+
"displayCondition": {
|
|
509
|
+
"type": "object",
|
|
510
|
+
"properties": {
|
|
511
|
+
"label": { "type": "string" },
|
|
512
|
+
"before": { "type": "string" },
|
|
513
|
+
"after": { "type": "string" },
|
|
514
|
+
"group": { "type": "string" },
|
|
515
|
+
"description": { "type": "string" }
|
|
516
|
+
},
|
|
517
|
+
"required": [
|
|
518
|
+
"label",
|
|
519
|
+
"before",
|
|
520
|
+
"after"
|
|
521
|
+
],
|
|
522
|
+
"additionalProperties": false
|
|
523
|
+
},
|
|
524
|
+
"text": { "type": "string" },
|
|
525
|
+
"url": { "type": "string" },
|
|
526
|
+
"openInNewTab": { "type": "boolean" },
|
|
527
|
+
"backgroundColor": { "type": "string" },
|
|
528
|
+
"textColor": { "type": "string" },
|
|
529
|
+
"borderRadius": {
|
|
530
|
+
"$ref": "#/definitions/BorderRadiusValue",
|
|
531
|
+
"description": "Corner radius in px — one number, or a radius per corner."
|
|
532
|
+
},
|
|
533
|
+
"border": {
|
|
534
|
+
"$ref": "#/definitions/BorderValue",
|
|
535
|
+
"description": "Border around the button itself, per side. Omitted = no border; a side with width 0 is not drawn. For an outline (\"ghost\") button, set `backgroundColor` to the keyword `\"transparent\"` (the background control stores it) and set `textColor` too — a new button is `#333333` with white text. An empty fill exports as `transparent`, because MJML paints `#414141` when the attribute is omitted."
|
|
536
|
+
},
|
|
537
|
+
"fontSize": { "type": "number" },
|
|
538
|
+
"buttonPadding": { "$ref": "#/definitions/SpacingValue" },
|
|
539
|
+
"fontFamily": { "type": "string" },
|
|
540
|
+
"width": { "anyOf": [{ "type": "number" }, {
|
|
541
|
+
"type": "string",
|
|
542
|
+
"const": "full"
|
|
543
|
+
}] },
|
|
544
|
+
"align": {
|
|
545
|
+
"type": "string",
|
|
546
|
+
"enum": [
|
|
547
|
+
"left",
|
|
548
|
+
"center",
|
|
549
|
+
"right"
|
|
550
|
+
],
|
|
551
|
+
"description": "Placement of the button within its column. No visible effect when `width` is `\"full\"`, since the button then spans the column."
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
"required": [
|
|
555
|
+
"align",
|
|
556
|
+
"backgroundColor",
|
|
557
|
+
"borderRadius",
|
|
558
|
+
"buttonPadding",
|
|
559
|
+
"fontSize",
|
|
560
|
+
"id",
|
|
561
|
+
"styles",
|
|
562
|
+
"text",
|
|
563
|
+
"textColor",
|
|
564
|
+
"type",
|
|
565
|
+
"url"
|
|
566
|
+
],
|
|
567
|
+
"additionalProperties": false
|
|
568
|
+
},
|
|
569
|
+
"DividerBlock": {
|
|
570
|
+
"type": "object",
|
|
571
|
+
"properties": {
|
|
572
|
+
"id": { "type": "string" },
|
|
573
|
+
"type": {
|
|
574
|
+
"type": "string",
|
|
575
|
+
"const": "divider"
|
|
576
|
+
},
|
|
577
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
578
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
579
|
+
"displayCondition": {
|
|
580
|
+
"type": "object",
|
|
581
|
+
"properties": {
|
|
582
|
+
"label": { "type": "string" },
|
|
583
|
+
"before": { "type": "string" },
|
|
584
|
+
"after": { "type": "string" },
|
|
585
|
+
"group": { "type": "string" },
|
|
586
|
+
"description": { "type": "string" }
|
|
587
|
+
},
|
|
588
|
+
"required": [
|
|
589
|
+
"label",
|
|
590
|
+
"before",
|
|
591
|
+
"after"
|
|
592
|
+
],
|
|
593
|
+
"additionalProperties": false
|
|
594
|
+
},
|
|
595
|
+
"lineStyle": {
|
|
596
|
+
"type": "string",
|
|
597
|
+
"enum": [
|
|
598
|
+
"solid",
|
|
599
|
+
"dashed",
|
|
600
|
+
"dotted"
|
|
601
|
+
]
|
|
602
|
+
},
|
|
603
|
+
"color": { "type": "string" },
|
|
604
|
+
"thickness": { "type": "number" },
|
|
605
|
+
"width": {
|
|
606
|
+
"anyOf": [
|
|
607
|
+
{ "type": "number" },
|
|
608
|
+
{
|
|
609
|
+
"type": "string",
|
|
610
|
+
"const": "full"
|
|
611
|
+
},
|
|
612
|
+
{ "$ref": "#/definitions/DividerPercentWidth" }
|
|
613
|
+
],
|
|
614
|
+
"description": "`\"full\"` spans the column and a number is pixels. A percentage is a share of the column: MJML renders it natively, and it shrinks with the column on a phone where a pixel width would overflow."
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
"required": [
|
|
618
|
+
"color",
|
|
619
|
+
"id",
|
|
620
|
+
"lineStyle",
|
|
621
|
+
"styles",
|
|
622
|
+
"thickness",
|
|
623
|
+
"type",
|
|
624
|
+
"width"
|
|
625
|
+
],
|
|
626
|
+
"additionalProperties": false
|
|
627
|
+
},
|
|
628
|
+
"DividerPercentWidth": {
|
|
629
|
+
"type": "string",
|
|
630
|
+
"description": "A share of the column, from `\"0%\"` to `\"100%\"`.",
|
|
631
|
+
"pattern": "^(?:100(?:\\.0+)?|\\d{1,2}(?:\\.\\d+)?)%$"
|
|
632
|
+
},
|
|
633
|
+
"VideoBlock": {
|
|
634
|
+
"type": "object",
|
|
635
|
+
"properties": {
|
|
636
|
+
"id": { "type": "string" },
|
|
637
|
+
"type": {
|
|
638
|
+
"type": "string",
|
|
639
|
+
"const": "video"
|
|
640
|
+
},
|
|
641
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
642
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
643
|
+
"displayCondition": {
|
|
644
|
+
"type": "object",
|
|
645
|
+
"properties": {
|
|
646
|
+
"label": { "type": "string" },
|
|
647
|
+
"before": { "type": "string" },
|
|
648
|
+
"after": { "type": "string" },
|
|
649
|
+
"group": { "type": "string" },
|
|
650
|
+
"description": { "type": "string" }
|
|
651
|
+
},
|
|
652
|
+
"required": [
|
|
653
|
+
"label",
|
|
654
|
+
"before",
|
|
655
|
+
"after"
|
|
656
|
+
],
|
|
657
|
+
"additionalProperties": false
|
|
658
|
+
},
|
|
659
|
+
"url": { "type": "string" },
|
|
660
|
+
"openInNewTab": { "type": "boolean" },
|
|
661
|
+
"thumbnailUrl": { "type": "string" },
|
|
662
|
+
"alt": { "type": "string" },
|
|
663
|
+
"width": { "anyOf": [{ "type": "number" }, {
|
|
664
|
+
"type": "string",
|
|
665
|
+
"const": "full"
|
|
666
|
+
}] },
|
|
667
|
+
"height": {
|
|
668
|
+
"type": "number",
|
|
669
|
+
"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`."
|
|
670
|
+
},
|
|
671
|
+
"align": {
|
|
672
|
+
"type": "string",
|
|
673
|
+
"enum": [
|
|
674
|
+
"left",
|
|
675
|
+
"center",
|
|
676
|
+
"right"
|
|
677
|
+
]
|
|
678
|
+
},
|
|
679
|
+
"placeholderUrl": { "type": "string" }
|
|
680
|
+
},
|
|
681
|
+
"required": [
|
|
682
|
+
"align",
|
|
683
|
+
"alt",
|
|
684
|
+
"id",
|
|
685
|
+
"styles",
|
|
686
|
+
"thumbnailUrl",
|
|
687
|
+
"type",
|
|
688
|
+
"url",
|
|
689
|
+
"width"
|
|
690
|
+
],
|
|
691
|
+
"additionalProperties": false
|
|
692
|
+
},
|
|
693
|
+
"SocialIconsBlock": {
|
|
694
|
+
"type": "object",
|
|
695
|
+
"properties": {
|
|
696
|
+
"id": { "type": "string" },
|
|
697
|
+
"type": {
|
|
698
|
+
"type": "string",
|
|
699
|
+
"const": "social"
|
|
700
|
+
},
|
|
701
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
702
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
703
|
+
"displayCondition": {
|
|
704
|
+
"type": "object",
|
|
705
|
+
"properties": {
|
|
706
|
+
"label": { "type": "string" },
|
|
707
|
+
"before": { "type": "string" },
|
|
708
|
+
"after": { "type": "string" },
|
|
709
|
+
"group": { "type": "string" },
|
|
710
|
+
"description": { "type": "string" }
|
|
711
|
+
},
|
|
712
|
+
"required": [
|
|
713
|
+
"label",
|
|
714
|
+
"before",
|
|
715
|
+
"after"
|
|
716
|
+
],
|
|
717
|
+
"additionalProperties": false
|
|
718
|
+
},
|
|
719
|
+
"icons": {
|
|
720
|
+
"type": "array",
|
|
721
|
+
"items": { "$ref": "#/definitions/SocialIcon" }
|
|
722
|
+
},
|
|
723
|
+
"iconStyle": { "$ref": "#/definitions/SocialIconStyle" },
|
|
724
|
+
"iconTone": {
|
|
725
|
+
"$ref": "#/definitions/SocialIconTone",
|
|
726
|
+
"description": "Absent means `\"brand\"`."
|
|
727
|
+
},
|
|
728
|
+
"iconSize": { "$ref": "#/definitions/SocialIconSize" },
|
|
729
|
+
"spacing": { "type": "number" },
|
|
730
|
+
"align": {
|
|
731
|
+
"type": "string",
|
|
732
|
+
"enum": [
|
|
733
|
+
"left",
|
|
734
|
+
"center",
|
|
735
|
+
"right"
|
|
736
|
+
]
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
"required": [
|
|
740
|
+
"align",
|
|
741
|
+
"iconSize",
|
|
742
|
+
"iconStyle",
|
|
743
|
+
"icons",
|
|
744
|
+
"id",
|
|
745
|
+
"spacing",
|
|
746
|
+
"styles",
|
|
747
|
+
"type"
|
|
748
|
+
],
|
|
749
|
+
"additionalProperties": false
|
|
750
|
+
},
|
|
751
|
+
"SocialIcon": {
|
|
752
|
+
"type": "object",
|
|
753
|
+
"properties": {
|
|
754
|
+
"id": { "type": "string" },
|
|
755
|
+
"platform": { "$ref": "#/definitions/SocialPlatform" },
|
|
756
|
+
"url": { "type": "string" }
|
|
757
|
+
},
|
|
758
|
+
"required": [
|
|
759
|
+
"id",
|
|
760
|
+
"platform",
|
|
761
|
+
"url"
|
|
762
|
+
],
|
|
763
|
+
"additionalProperties": false
|
|
764
|
+
},
|
|
765
|
+
"SocialPlatform": {
|
|
766
|
+
"type": "string",
|
|
767
|
+
"enum": [
|
|
768
|
+
"facebook",
|
|
769
|
+
"twitter",
|
|
770
|
+
"instagram",
|
|
771
|
+
"linkedin",
|
|
772
|
+
"youtube",
|
|
773
|
+
"tiktok",
|
|
774
|
+
"pinterest",
|
|
775
|
+
"email",
|
|
776
|
+
"whatsapp",
|
|
777
|
+
"telegram",
|
|
778
|
+
"discord",
|
|
779
|
+
"snapchat",
|
|
780
|
+
"reddit",
|
|
781
|
+
"github",
|
|
782
|
+
"dribbble",
|
|
783
|
+
"behance",
|
|
784
|
+
"website"
|
|
785
|
+
]
|
|
786
|
+
},
|
|
787
|
+
"SocialIconStyle": {
|
|
788
|
+
"type": "string",
|
|
789
|
+
"enum": [
|
|
790
|
+
"solid",
|
|
791
|
+
"outlined",
|
|
792
|
+
"rounded",
|
|
793
|
+
"square",
|
|
794
|
+
"circle",
|
|
795
|
+
"plain"
|
|
796
|
+
],
|
|
797
|
+
"description": "The icon's shape. `plain` is the glyph alone, with no badge or outline."
|
|
798
|
+
},
|
|
799
|
+
"SocialIconTone": {
|
|
800
|
+
"type": "string",
|
|
801
|
+
"enum": [
|
|
802
|
+
"brand",
|
|
803
|
+
"dark",
|
|
804
|
+
"light"
|
|
805
|
+
],
|
|
806
|
+
"description": "The colors an icon is drawn in: each platform's own brand color, or one tone (dark or light) for every icon."
|
|
807
|
+
},
|
|
808
|
+
"SocialIconSize": {
|
|
809
|
+
"type": "string",
|
|
810
|
+
"enum": [
|
|
811
|
+
"small",
|
|
812
|
+
"medium",
|
|
813
|
+
"large"
|
|
814
|
+
]
|
|
815
|
+
},
|
|
816
|
+
"SpacerBlock": {
|
|
817
|
+
"type": "object",
|
|
818
|
+
"properties": {
|
|
819
|
+
"id": { "type": "string" },
|
|
820
|
+
"type": {
|
|
821
|
+
"type": "string",
|
|
822
|
+
"const": "spacer"
|
|
823
|
+
},
|
|
824
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
825
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
826
|
+
"displayCondition": {
|
|
827
|
+
"type": "object",
|
|
828
|
+
"properties": {
|
|
829
|
+
"label": { "type": "string" },
|
|
830
|
+
"before": { "type": "string" },
|
|
831
|
+
"after": { "type": "string" },
|
|
832
|
+
"group": { "type": "string" },
|
|
833
|
+
"description": { "type": "string" }
|
|
834
|
+
},
|
|
835
|
+
"required": [
|
|
836
|
+
"label",
|
|
837
|
+
"before",
|
|
838
|
+
"after"
|
|
839
|
+
],
|
|
840
|
+
"additionalProperties": false
|
|
841
|
+
},
|
|
842
|
+
"height": { "type": "number" }
|
|
843
|
+
},
|
|
844
|
+
"required": [
|
|
845
|
+
"height",
|
|
846
|
+
"id",
|
|
847
|
+
"styles",
|
|
848
|
+
"type"
|
|
849
|
+
],
|
|
850
|
+
"additionalProperties": false
|
|
851
|
+
},
|
|
852
|
+
"HtmlBlock": {
|
|
853
|
+
"type": "object",
|
|
854
|
+
"properties": {
|
|
855
|
+
"id": { "type": "string" },
|
|
856
|
+
"type": {
|
|
857
|
+
"type": "string",
|
|
858
|
+
"const": "html"
|
|
859
|
+
},
|
|
860
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
861
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
862
|
+
"displayCondition": {
|
|
863
|
+
"type": "object",
|
|
864
|
+
"properties": {
|
|
865
|
+
"label": { "type": "string" },
|
|
866
|
+
"before": { "type": "string" },
|
|
867
|
+
"after": { "type": "string" },
|
|
868
|
+
"group": { "type": "string" },
|
|
869
|
+
"description": { "type": "string" }
|
|
870
|
+
},
|
|
871
|
+
"required": [
|
|
872
|
+
"label",
|
|
873
|
+
"before",
|
|
874
|
+
"after"
|
|
875
|
+
],
|
|
876
|
+
"additionalProperties": false
|
|
877
|
+
},
|
|
878
|
+
"content": { "type": "string" }
|
|
879
|
+
},
|
|
880
|
+
"required": [
|
|
881
|
+
"content",
|
|
882
|
+
"id",
|
|
883
|
+
"styles",
|
|
884
|
+
"type"
|
|
885
|
+
],
|
|
886
|
+
"additionalProperties": false
|
|
887
|
+
},
|
|
888
|
+
"MenuBlock": {
|
|
889
|
+
"type": "object",
|
|
890
|
+
"properties": {
|
|
891
|
+
"id": { "type": "string" },
|
|
892
|
+
"type": {
|
|
893
|
+
"type": "string",
|
|
894
|
+
"const": "menu"
|
|
895
|
+
},
|
|
896
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
897
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
898
|
+
"displayCondition": {
|
|
899
|
+
"type": "object",
|
|
900
|
+
"properties": {
|
|
901
|
+
"label": { "type": "string" },
|
|
902
|
+
"before": { "type": "string" },
|
|
903
|
+
"after": { "type": "string" },
|
|
904
|
+
"group": { "type": "string" },
|
|
905
|
+
"description": { "type": "string" }
|
|
906
|
+
},
|
|
907
|
+
"required": [
|
|
908
|
+
"label",
|
|
909
|
+
"before",
|
|
910
|
+
"after"
|
|
911
|
+
],
|
|
912
|
+
"additionalProperties": false
|
|
913
|
+
},
|
|
914
|
+
"items": {
|
|
915
|
+
"type": "array",
|
|
916
|
+
"items": { "$ref": "#/definitions/MenuItemData" }
|
|
917
|
+
},
|
|
918
|
+
"fontSize": { "type": "number" },
|
|
919
|
+
"fontFamily": { "type": "string" },
|
|
920
|
+
"color": {
|
|
921
|
+
"type": "string",
|
|
922
|
+
"description": "Base text/link color. Unset = inherit the document-level `textColor`."
|
|
923
|
+
},
|
|
924
|
+
"linkColor": { "type": "string" },
|
|
925
|
+
"textAlign": {
|
|
926
|
+
"type": "string",
|
|
927
|
+
"enum": [
|
|
928
|
+
"left",
|
|
929
|
+
"center",
|
|
930
|
+
"right"
|
|
931
|
+
]
|
|
932
|
+
},
|
|
933
|
+
"separator": { "type": "string" },
|
|
934
|
+
"separatorColor": { "type": "string" },
|
|
935
|
+
"spacing": { "type": "number" }
|
|
936
|
+
},
|
|
937
|
+
"required": [
|
|
938
|
+
"fontSize",
|
|
939
|
+
"id",
|
|
940
|
+
"items",
|
|
941
|
+
"separator",
|
|
942
|
+
"separatorColor",
|
|
943
|
+
"spacing",
|
|
944
|
+
"styles",
|
|
945
|
+
"textAlign",
|
|
946
|
+
"type"
|
|
947
|
+
],
|
|
948
|
+
"additionalProperties": false
|
|
949
|
+
},
|
|
950
|
+
"MenuItemData": {
|
|
951
|
+
"type": "object",
|
|
952
|
+
"properties": {
|
|
953
|
+
"id": { "type": "string" },
|
|
954
|
+
"text": { "type": "string" },
|
|
955
|
+
"url": { "type": "string" },
|
|
956
|
+
"openInNewTab": { "type": "boolean" },
|
|
957
|
+
"bold": { "type": "boolean" },
|
|
958
|
+
"underline": { "type": "boolean" },
|
|
959
|
+
"color": { "type": "string" }
|
|
960
|
+
},
|
|
961
|
+
"required": [
|
|
962
|
+
"id",
|
|
963
|
+
"text",
|
|
964
|
+
"url",
|
|
965
|
+
"openInNewTab",
|
|
966
|
+
"bold",
|
|
967
|
+
"underline"
|
|
968
|
+
],
|
|
969
|
+
"additionalProperties": false
|
|
970
|
+
},
|
|
971
|
+
"TableBlock": {
|
|
972
|
+
"type": "object",
|
|
973
|
+
"properties": {
|
|
974
|
+
"id": { "type": "string" },
|
|
975
|
+
"type": {
|
|
976
|
+
"type": "string",
|
|
977
|
+
"const": "table"
|
|
978
|
+
},
|
|
979
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
980
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
981
|
+
"displayCondition": {
|
|
982
|
+
"type": "object",
|
|
983
|
+
"properties": {
|
|
984
|
+
"label": { "type": "string" },
|
|
985
|
+
"before": { "type": "string" },
|
|
986
|
+
"after": { "type": "string" },
|
|
987
|
+
"group": { "type": "string" },
|
|
988
|
+
"description": { "type": "string" }
|
|
989
|
+
},
|
|
990
|
+
"required": [
|
|
991
|
+
"label",
|
|
992
|
+
"before",
|
|
993
|
+
"after"
|
|
994
|
+
],
|
|
995
|
+
"additionalProperties": false
|
|
996
|
+
},
|
|
997
|
+
"rows": {
|
|
998
|
+
"type": "array",
|
|
999
|
+
"items": { "$ref": "#/definitions/TableRowData" }
|
|
1000
|
+
},
|
|
1001
|
+
"hasHeaderRow": { "type": "boolean" },
|
|
1002
|
+
"headerBackgroundColor": { "type": "string" },
|
|
1003
|
+
"borderColor": { "type": "string" },
|
|
1004
|
+
"borderWidth": { "type": "number" },
|
|
1005
|
+
"cellPadding": { "type": "number" },
|
|
1006
|
+
"fontSize": { "type": "number" },
|
|
1007
|
+
"fontFamily": { "type": "string" },
|
|
1008
|
+
"color": {
|
|
1009
|
+
"type": "string",
|
|
1010
|
+
"description": "Text color. Unset = inherit the document-level `textColor`."
|
|
1011
|
+
},
|
|
1012
|
+
"textAlign": {
|
|
1013
|
+
"type": "string",
|
|
1014
|
+
"enum": [
|
|
1015
|
+
"left",
|
|
1016
|
+
"center",
|
|
1017
|
+
"right"
|
|
1018
|
+
]
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
"required": [
|
|
1022
|
+
"borderColor",
|
|
1023
|
+
"borderWidth",
|
|
1024
|
+
"cellPadding",
|
|
1025
|
+
"fontSize",
|
|
1026
|
+
"hasHeaderRow",
|
|
1027
|
+
"id",
|
|
1028
|
+
"rows",
|
|
1029
|
+
"styles",
|
|
1030
|
+
"textAlign",
|
|
1031
|
+
"type"
|
|
1032
|
+
],
|
|
1033
|
+
"additionalProperties": false
|
|
1034
|
+
},
|
|
1035
|
+
"TableRowData": {
|
|
1036
|
+
"type": "object",
|
|
1037
|
+
"properties": {
|
|
1038
|
+
"id": { "type": "string" },
|
|
1039
|
+
"cells": {
|
|
1040
|
+
"type": "array",
|
|
1041
|
+
"items": { "$ref": "#/definitions/TableCellData" }
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
"required": ["id", "cells"],
|
|
1045
|
+
"additionalProperties": false
|
|
1046
|
+
},
|
|
1047
|
+
"TableCellData": {
|
|
1048
|
+
"type": "object",
|
|
1049
|
+
"properties": {
|
|
1050
|
+
"id": { "type": "string" },
|
|
1051
|
+
"content": { "type": "string" }
|
|
1052
|
+
},
|
|
1053
|
+
"required": ["id", "content"],
|
|
1054
|
+
"additionalProperties": false
|
|
1055
|
+
},
|
|
1056
|
+
"CountdownBlock": {
|
|
1057
|
+
"type": "object",
|
|
1058
|
+
"properties": {
|
|
1059
|
+
"id": { "type": "string" },
|
|
1060
|
+
"type": {
|
|
1061
|
+
"type": "string",
|
|
1062
|
+
"const": "countdown"
|
|
1063
|
+
},
|
|
1064
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
1065
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
1066
|
+
"displayCondition": {
|
|
1067
|
+
"type": "object",
|
|
1068
|
+
"properties": {
|
|
1069
|
+
"label": { "type": "string" },
|
|
1070
|
+
"before": { "type": "string" },
|
|
1071
|
+
"after": { "type": "string" },
|
|
1072
|
+
"group": { "type": "string" },
|
|
1073
|
+
"description": { "type": "string" }
|
|
1074
|
+
},
|
|
1075
|
+
"required": [
|
|
1076
|
+
"label",
|
|
1077
|
+
"before",
|
|
1078
|
+
"after"
|
|
1079
|
+
],
|
|
1080
|
+
"additionalProperties": false
|
|
1081
|
+
},
|
|
1082
|
+
"targetDate": { "type": "string" },
|
|
1083
|
+
"timezone": { "type": "string" },
|
|
1084
|
+
"showDays": { "type": "boolean" },
|
|
1085
|
+
"showHours": { "type": "boolean" },
|
|
1086
|
+
"showMinutes": { "type": "boolean" },
|
|
1087
|
+
"showSeconds": { "type": "boolean" },
|
|
1088
|
+
"separator": {
|
|
1089
|
+
"type": "string",
|
|
1090
|
+
"enum": [
|
|
1091
|
+
":",
|
|
1092
|
+
"-",
|
|
1093
|
+
" "
|
|
1094
|
+
]
|
|
1095
|
+
},
|
|
1096
|
+
"digitFontSize": { "type": "number" },
|
|
1097
|
+
"digitColor": { "type": "string" },
|
|
1098
|
+
"labelColor": { "type": "string" },
|
|
1099
|
+
"labelFontSize": { "type": "number" },
|
|
1100
|
+
"backgroundColor": { "type": "string" },
|
|
1101
|
+
"fontFamily": { "type": "string" },
|
|
1102
|
+
"labelDays": { "type": "string" },
|
|
1103
|
+
"labelHours": { "type": "string" },
|
|
1104
|
+
"labelMinutes": { "type": "string" },
|
|
1105
|
+
"labelSeconds": { "type": "string" },
|
|
1106
|
+
"expiredMessage": { "type": "string" },
|
|
1107
|
+
"expiredImageUrl": { "type": "string" },
|
|
1108
|
+
"hideOnExpiry": { "type": "boolean" }
|
|
1109
|
+
},
|
|
1110
|
+
"required": [
|
|
1111
|
+
"backgroundColor",
|
|
1112
|
+
"digitColor",
|
|
1113
|
+
"digitFontSize",
|
|
1114
|
+
"expiredImageUrl",
|
|
1115
|
+
"expiredMessage",
|
|
1116
|
+
"hideOnExpiry",
|
|
1117
|
+
"id",
|
|
1118
|
+
"labelColor",
|
|
1119
|
+
"labelDays",
|
|
1120
|
+
"labelFontSize",
|
|
1121
|
+
"labelHours",
|
|
1122
|
+
"labelMinutes",
|
|
1123
|
+
"labelSeconds",
|
|
1124
|
+
"separator",
|
|
1125
|
+
"showDays",
|
|
1126
|
+
"showHours",
|
|
1127
|
+
"showMinutes",
|
|
1128
|
+
"showSeconds",
|
|
1129
|
+
"styles",
|
|
1130
|
+
"targetDate",
|
|
1131
|
+
"timezone",
|
|
1132
|
+
"type"
|
|
1133
|
+
],
|
|
1134
|
+
"additionalProperties": false
|
|
1135
|
+
},
|
|
1136
|
+
"CustomBlock": {
|
|
1137
|
+
"type": "object",
|
|
1138
|
+
"properties": {
|
|
1139
|
+
"id": { "type": "string" },
|
|
1140
|
+
"type": {
|
|
1141
|
+
"type": "string",
|
|
1142
|
+
"const": "custom"
|
|
1143
|
+
},
|
|
1144
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
1145
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
1146
|
+
"displayCondition": {
|
|
1147
|
+
"type": "object",
|
|
1148
|
+
"properties": {
|
|
1149
|
+
"label": { "type": "string" },
|
|
1150
|
+
"before": { "type": "string" },
|
|
1151
|
+
"after": { "type": "string" },
|
|
1152
|
+
"group": { "type": "string" },
|
|
1153
|
+
"description": { "type": "string" }
|
|
1154
|
+
},
|
|
1155
|
+
"required": [
|
|
1156
|
+
"label",
|
|
1157
|
+
"before",
|
|
1158
|
+
"after"
|
|
1159
|
+
],
|
|
1160
|
+
"additionalProperties": false
|
|
1161
|
+
},
|
|
1162
|
+
"customType": { "type": "string" },
|
|
1163
|
+
"fieldValues": {
|
|
1164
|
+
"type": "object",
|
|
1165
|
+
"additionalProperties": {}
|
|
1166
|
+
},
|
|
1167
|
+
"renderedHtml": { "type": "string" },
|
|
1168
|
+
"renderedStylesheet": {
|
|
1169
|
+
"type": "string",
|
|
1170
|
+
"description": "The definition's `stylesheet`, copied in when `renderedHtml` is written. A render provider's backend, and Cloud rendering from storage, have no definition to ask, so `@templatical/renderer` emits this when no `getCustomBlockStylesheet` resolver is passed. A resolver, when passed, decides every type, so a stale copy never overrides a live definition."
|
|
1171
|
+
},
|
|
1172
|
+
"dataSourceFetched": { "type": "boolean" }
|
|
1173
|
+
},
|
|
1174
|
+
"required": [
|
|
1175
|
+
"customType",
|
|
1176
|
+
"fieldValues",
|
|
1177
|
+
"id",
|
|
1178
|
+
"styles",
|
|
1179
|
+
"type"
|
|
1180
|
+
],
|
|
1181
|
+
"additionalProperties": false
|
|
1182
|
+
},
|
|
1183
|
+
"SlotBlock": {
|
|
1184
|
+
"type": "object",
|
|
1185
|
+
"properties": {
|
|
1186
|
+
"id": { "type": "string" },
|
|
1187
|
+
"type": {
|
|
1188
|
+
"type": "string",
|
|
1189
|
+
"const": "slot"
|
|
1190
|
+
},
|
|
1191
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
1192
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
1193
|
+
"displayCondition": {
|
|
1194
|
+
"type": "object",
|
|
1195
|
+
"properties": {
|
|
1196
|
+
"label": { "type": "string" },
|
|
1197
|
+
"before": { "type": "string" },
|
|
1198
|
+
"after": { "type": "string" },
|
|
1199
|
+
"group": { "type": "string" },
|
|
1200
|
+
"description": { "type": "string" }
|
|
1201
|
+
},
|
|
1202
|
+
"required": [
|
|
1203
|
+
"label",
|
|
1204
|
+
"before",
|
|
1205
|
+
"after"
|
|
1206
|
+
],
|
|
1207
|
+
"additionalProperties": false
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
"required": [
|
|
1211
|
+
"id",
|
|
1212
|
+
"styles",
|
|
1213
|
+
"type"
|
|
1214
|
+
],
|
|
1215
|
+
"additionalProperties": false,
|
|
1216
|
+
"description": "Layout-only hole where authored content is spliced in. Extends BaseBlock so Block walkers stay typed (`styles` unused; spliced out before render)."
|
|
1217
|
+
},
|
|
1218
|
+
"WrapperBlock": {
|
|
1219
|
+
"type": "object",
|
|
1220
|
+
"properties": {
|
|
1221
|
+
"id": { "type": "string" },
|
|
1222
|
+
"type": {
|
|
1223
|
+
"type": "string",
|
|
1224
|
+
"const": "wrapper"
|
|
1225
|
+
},
|
|
1226
|
+
"styles": { "$ref": "#/definitions/BlockStyles" },
|
|
1227
|
+
"visibility": { "$ref": "#/definitions/BlockVisibility" },
|
|
1228
|
+
"displayCondition": {
|
|
1229
|
+
"type": "object",
|
|
1230
|
+
"properties": {
|
|
1231
|
+
"label": { "type": "string" },
|
|
1232
|
+
"before": { "type": "string" },
|
|
1233
|
+
"after": { "type": "string" },
|
|
1234
|
+
"group": { "type": "string" },
|
|
1235
|
+
"description": { "type": "string" }
|
|
1236
|
+
},
|
|
1237
|
+
"required": [
|
|
1238
|
+
"label",
|
|
1239
|
+
"before",
|
|
1240
|
+
"after"
|
|
1241
|
+
],
|
|
1242
|
+
"additionalProperties": false
|
|
1243
|
+
},
|
|
1244
|
+
"children": {
|
|
1245
|
+
"type": "array",
|
|
1246
|
+
"items": { "$ref": "#/definitions/Block" }
|
|
1247
|
+
},
|
|
1248
|
+
"borderRadius": { "type": "number" },
|
|
1249
|
+
"backgroundImage": {
|
|
1250
|
+
"$ref": "#/definitions/BackgroundImage",
|
|
1251
|
+
"description": "Image behind the band; `styles.backgroundColor` is its fallback."
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
"required": [
|
|
1255
|
+
"children",
|
|
1256
|
+
"id",
|
|
1257
|
+
"styles",
|
|
1258
|
+
"type"
|
|
1259
|
+
],
|
|
1260
|
+
"additionalProperties": false,
|
|
1261
|
+
"description": "Layout-only band rendered as `mj-wrapper`. The block is the band: `styles.backgroundColor` / `styles.padding` / `borderRadius` / `backgroundImage` map to the wrapper. Legal in layout only."
|
|
1262
|
+
},
|
|
1263
|
+
"TemplateSettings": {
|
|
1264
|
+
"type": "object",
|
|
1265
|
+
"properties": {
|
|
1266
|
+
"width": { "type": "number" },
|
|
1267
|
+
"backgroundColor": { "type": "string" },
|
|
1268
|
+
"textColor": {
|
|
1269
|
+
"type": "string",
|
|
1270
|
+
"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."
|
|
1271
|
+
},
|
|
1272
|
+
"linkColor": {
|
|
1273
|
+
"type": "string",
|
|
1274
|
+
"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."
|
|
1275
|
+
},
|
|
1276
|
+
"linkUnderline": {
|
|
1277
|
+
"type": "boolean",
|
|
1278
|
+
"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."
|
|
1279
|
+
},
|
|
1280
|
+
"fontFamily": { "type": "string" },
|
|
1281
|
+
"preheaderText": { "type": "string" },
|
|
1282
|
+
"locale": {
|
|
1283
|
+
"type": "string",
|
|
1284
|
+
"description": "BCP-47 language code for the rendered email's `<html lang>`. Drives screen-reader pronunciation. Default `'en'` via `DEFAULT_TEMPLATE_DEFAULTS`."
|
|
1285
|
+
},
|
|
1286
|
+
"direction": {
|
|
1287
|
+
"$ref": "#/definitions/ContentDirection",
|
|
1288
|
+
"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."
|
|
1289
|
+
}
|
|
1290
|
+
},
|
|
1291
|
+
"required": [
|
|
1292
|
+
"width",
|
|
1293
|
+
"backgroundColor",
|
|
1294
|
+
"textColor",
|
|
1295
|
+
"linkUnderline",
|
|
1296
|
+
"fontFamily",
|
|
1297
|
+
"locale"
|
|
1298
|
+
],
|
|
1299
|
+
"additionalProperties": false
|
|
1300
|
+
},
|
|
1301
|
+
"ContentDirection": {
|
|
1302
|
+
"type": "string",
|
|
1303
|
+
"enum": ["ltr", "rtl"],
|
|
1304
|
+
"description": "Writing direction of the delivered email — the canvas `dir` and the rendered `<mjml dir>`. Independent of the editor chrome language (`init({ locale })`)."
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
const ajv = new Ajv({
|
|
1309
|
+
allErrors: true,
|
|
1310
|
+
strict: false
|
|
1311
|
+
});
|
|
1312
|
+
const typeToDef = {};
|
|
1313
|
+
for (const [name, def] of Object.entries(schema.definitions)) {
|
|
1314
|
+
const constType = def?.properties?.type?.const;
|
|
1315
|
+
if (typeof constType === "string") typeToDef[constType] = name;
|
|
1316
|
+
}
|
|
1317
|
+
const validatorCache = /* @__PURE__ */ new Map();
|
|
1318
|
+
function validatorFor(defName) {
|
|
1319
|
+
const cached = validatorCache.get(defName);
|
|
1320
|
+
if (cached) return cached;
|
|
1321
|
+
const definitions = structuredClone(schema.definitions);
|
|
1322
|
+
if (defName === "SectionBlock") {
|
|
1323
|
+
const section = definitions.SectionBlock;
|
|
1324
|
+
if (section?.properties) section.properties.children = { type: "array" };
|
|
1325
|
+
}
|
|
1326
|
+
const compiled = ajv.compile({
|
|
1327
|
+
$ref: `#/definitions/${defName}`,
|
|
1328
|
+
definitions
|
|
1329
|
+
});
|
|
1330
|
+
validatorCache.set(defName, compiled);
|
|
1331
|
+
return compiled;
|
|
1332
|
+
}
|
|
1333
|
+
const settingsValidator = ajv.compile({
|
|
1334
|
+
$ref: "#/definitions/TemplateSettings",
|
|
1335
|
+
definitions: structuredClone(schema.definitions)
|
|
1336
|
+
});
|
|
1337
|
+
function flattenBlocks(blocks, basePath, out) {
|
|
1338
|
+
if (!Array.isArray(blocks)) return;
|
|
1339
|
+
blocks.forEach((block, i) => {
|
|
1340
|
+
const path = `${basePath}[${i}]`;
|
|
1341
|
+
out.push({
|
|
1342
|
+
path,
|
|
1343
|
+
block: block ?? null
|
|
1344
|
+
});
|
|
1345
|
+
if (block?.type === "section" && Array.isArray(block.children)) block.children.forEach((column, ci) => {
|
|
1346
|
+
flattenBlocks(column, `${path}.children[${ci}]`, out);
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function formatAjvErrors(errors, prefix) {
|
|
1351
|
+
return (errors ?? []).map((e) => {
|
|
1352
|
+
const where = `${prefix}${e.instancePath}`;
|
|
1353
|
+
const extra = e.keyword === "additionalProperties" ? ` (${e.params.additionalProperty})` : "";
|
|
1354
|
+
return `${where} ${e.message}${extra}`;
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Structural validation. Synchronous, depends only on ajv + the committed
|
|
1359
|
+
* schema.json (no build of the workspace packages required).
|
|
1360
|
+
*/
|
|
1361
|
+
function validateTemplate(data) {
|
|
1362
|
+
const errors = [];
|
|
1363
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return {
|
|
1364
|
+
valid: false,
|
|
1365
|
+
errors: ["(root) must be an object"]
|
|
1366
|
+
};
|
|
1367
|
+
const doc = data;
|
|
1368
|
+
if (!Array.isArray(doc.blocks)) errors.push("blocks must be an array");
|
|
1369
|
+
if (doc.settings === null || typeof doc.settings !== "object") errors.push("settings must be an object");
|
|
1370
|
+
else if (!settingsValidator(doc.settings)) errors.push(...formatAjvErrors(settingsValidator.errors, "settings"));
|
|
1371
|
+
const flat = [];
|
|
1372
|
+
flattenBlocks(doc.blocks, "blocks", flat);
|
|
1373
|
+
for (const { path, block } of flat) {
|
|
1374
|
+
const type = block?.type;
|
|
1375
|
+
const defName = typeof type === "string" ? typeToDef[type] : void 0;
|
|
1376
|
+
if (!defName) {
|
|
1377
|
+
const known = Object.keys(typeToDef).join(", ");
|
|
1378
|
+
errors.push(`${path} has unknown or missing block type ${JSON.stringify(type)} (expected one of: ${known})`);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
const validate = validatorFor(defName);
|
|
1382
|
+
if (!validate(block)) errors.push(...formatAjvErrors(validate.errors, `${path} (${type})`));
|
|
1383
|
+
}
|
|
1384
|
+
return {
|
|
1385
|
+
valid: errors.length === 0,
|
|
1386
|
+
errors
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Quality layer — accessibility / structure / link linting.
|
|
1391
|
+
*
|
|
1392
|
+
* Assumes structurally-valid input, so callers should run `validateTemplate`
|
|
1393
|
+
* first; the try/catch is a guard against a malformed template crashing the
|
|
1394
|
+
* linter rather than a substitute for that ordering.
|
|
1395
|
+
*/
|
|
1396
|
+
function runQualityLint(data) {
|
|
1397
|
+
try {
|
|
1398
|
+
return { issues: lintTemplate(data) ?? [] };
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
return {
|
|
1401
|
+
issues: [],
|
|
1402
|
+
error: err.message
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
//#endregion
|
|
1407
|
+
//#region src/custom-block/safety.ts
|
|
1408
|
+
const FLEX_GRID = /(^|;)\s*display\s*:\s*(inline-)?(flex|grid)\b/i;
|
|
1409
|
+
const POSITION = /(^|;)\s*position\s*:\s*(absolute|fixed)\b/i;
|
|
1410
|
+
const DIV_LAYOUT = /(^|;)\s*(width|max-width|float)\s*:/i;
|
|
1411
|
+
const CSS_VARIABLE = /\bvar\(\s*--/i;
|
|
1412
|
+
const COLOR_VALUE = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\(/i;
|
|
1413
|
+
const FORM_CONTROLS = /* @__PURE__ */ new Set([
|
|
1414
|
+
"form",
|
|
1415
|
+
"input",
|
|
1416
|
+
"select",
|
|
1417
|
+
"textarea"
|
|
1418
|
+
]);
|
|
1419
|
+
const CSS_FLEX_GRID = /(^|[;{\s])display\s*:\s*(inline-)?(flex|grid)\b/i;
|
|
1420
|
+
const CSS_POSITION = /(^|[;{\s])position\s*:\s*(absolute|fixed)\b/i;
|
|
1421
|
+
const IMPORT_MESSAGE = "`@import` is dropped by most email clients; inline the rules.";
|
|
1422
|
+
const FLEX_GRID_MESSAGE = "`display: flex`/`grid` is ignored by Outlook and Gmail on many clients; use tables.";
|
|
1423
|
+
const POSITION_MESSAGE = "`position: absolute/fixed` is unsupported in email clients.";
|
|
1424
|
+
const CSS_VARIABLE_MESSAGE = "CSS custom properties (`var(--…)`) are dropped by Gmail and Outlook, and the whole declaration with them; write the value itself.";
|
|
1425
|
+
/** A style attribute's declarations as lowercase property → raw value pairs. */
|
|
1426
|
+
function declarations(style) {
|
|
1427
|
+
const out = [];
|
|
1428
|
+
for (const part of style.split(";")) {
|
|
1429
|
+
const colon = part.indexOf(":");
|
|
1430
|
+
if (colon > 0) out.push([part.slice(0, colon).trim().toLowerCase(), part.slice(colon + 1)]);
|
|
1431
|
+
}
|
|
1432
|
+
return out;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Whether a background image here has a colour to fall back on when images
|
|
1436
|
+
* are blocked: `background-color`, a colour inside the `background:`
|
|
1437
|
+
* shorthand, or the `bgcolor` attribute Outlook reads.
|
|
1438
|
+
*/
|
|
1439
|
+
function backgroundIssue(style, attrs) {
|
|
1440
|
+
const decls = declarations(style);
|
|
1441
|
+
if (!decls.some(([prop, value]) => (prop === "background" || prop === "background-image") && /url\(/i.test(value)) || "bgcolor" in attrs) return false;
|
|
1442
|
+
return !decls.some(([prop, value]) => prop === "background-color" || prop === "background" && COLOR_VALUE.test(stripCssUrlsAndStrings(value)));
|
|
1443
|
+
}
|
|
1444
|
+
/** Drops `/* … *\/` comments in one linear pass; an unterminated comment drops the rest. */
|
|
1445
|
+
function stripCssComments(css) {
|
|
1446
|
+
let out = "";
|
|
1447
|
+
let i = 0;
|
|
1448
|
+
for (;;) {
|
|
1449
|
+
const open = css.indexOf("/*", i);
|
|
1450
|
+
if (open === -1) return out + css.slice(i);
|
|
1451
|
+
out += css.slice(i, open);
|
|
1452
|
+
const close = css.indexOf("*/", open + 2);
|
|
1453
|
+
if (close === -1) return out;
|
|
1454
|
+
i = close + 2;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Remove `url(...)` and quoted strings so the class scan can't read `.png` or
|
|
1459
|
+
* `"x.y"` as a selector. Linear: each opener's next position is searched for
|
|
1460
|
+
* again only once the cursor has passed it, so an opener that never closes
|
|
1461
|
+
* costs one scan, not one per occurrence. An unterminated `url(` or quote
|
|
1462
|
+
* drops the rest, as a CSS parser would.
|
|
1463
|
+
*/
|
|
1464
|
+
function stripCssUrlsAndStrings(css) {
|
|
1465
|
+
const lower = css.toLowerCase();
|
|
1466
|
+
const next = (needle, from) => {
|
|
1467
|
+
const at = lower.indexOf(needle, from);
|
|
1468
|
+
return at === -1 ? Infinity : at;
|
|
1469
|
+
};
|
|
1470
|
+
const closers = {
|
|
1471
|
+
"url(": ")",
|
|
1472
|
+
"\"": "\"",
|
|
1473
|
+
"'": "'"
|
|
1474
|
+
};
|
|
1475
|
+
const pos = {
|
|
1476
|
+
"url(": 0,
|
|
1477
|
+
"\"": 0,
|
|
1478
|
+
"'": 0
|
|
1479
|
+
};
|
|
1480
|
+
for (const k of Object.keys(pos)) pos[k] = next(k, 0);
|
|
1481
|
+
let out = "";
|
|
1482
|
+
let i = 0;
|
|
1483
|
+
for (;;) {
|
|
1484
|
+
let opener = "";
|
|
1485
|
+
let open = Infinity;
|
|
1486
|
+
for (const k of Object.keys(pos)) {
|
|
1487
|
+
if (pos[k] < i) pos[k] = next(k, i);
|
|
1488
|
+
if (pos[k] < open) {
|
|
1489
|
+
open = pos[k];
|
|
1490
|
+
opener = k;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (open === Infinity) return out + css.slice(i);
|
|
1494
|
+
out += css.slice(i, open);
|
|
1495
|
+
const close = lower.indexOf(closers[opener], open + opener.length);
|
|
1496
|
+
if (close === -1) return out;
|
|
1497
|
+
i = close + 1;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/** Layout rules that hold for stylesheet text: the definition's and a `<style>`'s. */
|
|
1501
|
+
function cssLayoutIssues(css, path) {
|
|
1502
|
+
const text = stripCssComments(css);
|
|
1503
|
+
const issues = [];
|
|
1504
|
+
const at = path === void 0 ? {} : { path };
|
|
1505
|
+
if (CSS_FLEX_GRID.test(text)) issues.push({
|
|
1506
|
+
ruleId: "safety.flex-grid",
|
|
1507
|
+
severity: "error",
|
|
1508
|
+
...at,
|
|
1509
|
+
message: FLEX_GRID_MESSAGE
|
|
1510
|
+
});
|
|
1511
|
+
if (CSS_POSITION.test(text)) issues.push({
|
|
1512
|
+
ruleId: "safety.position",
|
|
1513
|
+
severity: "error",
|
|
1514
|
+
...at,
|
|
1515
|
+
message: POSITION_MESSAGE
|
|
1516
|
+
});
|
|
1517
|
+
if (CSS_VARIABLE.test(text)) issues.push({
|
|
1518
|
+
ruleId: "safety.css-variable",
|
|
1519
|
+
severity: "warning",
|
|
1520
|
+
...at,
|
|
1521
|
+
message: CSS_VARIABLE_MESSAGE
|
|
1522
|
+
});
|
|
1523
|
+
return issues;
|
|
1524
|
+
}
|
|
1525
|
+
function stylesheetIssues(def, css) {
|
|
1526
|
+
const issues = [];
|
|
1527
|
+
if (/@import\b/i.test(stripCssComments(css))) issues.push({
|
|
1528
|
+
ruleId: "safety.import",
|
|
1529
|
+
severity: "error",
|
|
1530
|
+
path: "/stylesheet",
|
|
1531
|
+
message: IMPORT_MESSAGE
|
|
1532
|
+
});
|
|
1533
|
+
issues.push(...cssLayoutIssues(css, "/stylesheet"));
|
|
1534
|
+
const stripped = stripCssUrlsAndStrings(stripCssComments(css));
|
|
1535
|
+
const prefix = `tplc-${def.type}-`;
|
|
1536
|
+
const unscoped = [...new Set([...stripped.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1]))].filter((cls) => !cls.startsWith(prefix));
|
|
1537
|
+
if (unscoped.length > 0) issues.push({
|
|
1538
|
+
ruleId: "safety.unscoped-class",
|
|
1539
|
+
severity: "warning",
|
|
1540
|
+
path: "/stylesheet",
|
|
1541
|
+
message: `Classes ${unscoped.map((c) => `.${c}`).join(", ")} aren't prefixed \`${prefix}\`; the SDK doesn't scope them, so they can collide with other blocks.`
|
|
1542
|
+
});
|
|
1543
|
+
return issues;
|
|
1544
|
+
}
|
|
1545
|
+
function checkEmailSafety(def, rendered) {
|
|
1546
|
+
const issues = [];
|
|
1547
|
+
const add = (ruleId, severity, message) => issues.push({
|
|
1548
|
+
ruleId,
|
|
1549
|
+
severity,
|
|
1550
|
+
message
|
|
1551
|
+
});
|
|
1552
|
+
for (const { state, html } of rendered) {
|
|
1553
|
+
let inStyle = false;
|
|
1554
|
+
let styleText = "";
|
|
1555
|
+
const parser = new Parser({
|
|
1556
|
+
onopentag(name, attrs) {
|
|
1557
|
+
const style = attrs.style ?? "";
|
|
1558
|
+
if (FLEX_GRID.test(style)) add("safety.flex-grid", "error", FLEX_GRID_MESSAGE);
|
|
1559
|
+
if (POSITION.test(style)) add("safety.position", "error", POSITION_MESSAGE);
|
|
1560
|
+
if (CSS_VARIABLE.test(style)) add("safety.css-variable", "warning", CSS_VARIABLE_MESSAGE);
|
|
1561
|
+
if (backgroundIssue(style, attrs)) add("safety.bg-image-fallback", "warning", "A background image needs a fallback colour (`background-color`, a colour in `background:`, or `bgcolor`); many clients block images.");
|
|
1562
|
+
if (name === "svg") add("safety.svg", "error", "Inline `<svg>` is stripped by Gmail and not drawn by Outlook; use a PNG `<img>`.");
|
|
1563
|
+
if (FORM_CONTROLS.has(name)) add("safety.form", "warning", `\`<${name}>\` doesn't work in most email clients (Gmail and Outlook disable forms); link to a page instead.`);
|
|
1564
|
+
if (name === "script") add("safety.script", "error", "`<script>` is stripped by every email client.");
|
|
1565
|
+
if (name === "link" && (attrs.rel ?? "").toLowerCase() === "stylesheet") add("safety.external-stylesheet", "error", "External stylesheets are not loaded by email clients; use inline styles or `stylesheet`.");
|
|
1566
|
+
if (name === "style") {
|
|
1567
|
+
inStyle = true;
|
|
1568
|
+
styleText = "";
|
|
1569
|
+
add("safety.style-tag", "warning", "Move `<style>` rules into the definition's `stylesheet`, which the renderer places in `<mj-head>`.");
|
|
1570
|
+
}
|
|
1571
|
+
if (name === "div" && DIV_LAYOUT.test(style)) add("safety.div-layout", "warning", "A `<div>` carrying width or float is unreliable in Outlook; lay out with tables.");
|
|
1572
|
+
if (name === "img") {
|
|
1573
|
+
if (!("alt" in attrs)) add("safety.img-alt", "warning", "Every `<img>` needs an `alt` attribute (`alt=\"\"` for decorative images), ideally bound to a field.");
|
|
1574
|
+
if (!("width" in attrs)) add("safety.img-width", "warning", "Give `<img>` a `width` attribute; Outlook ignores CSS widths on images.");
|
|
1575
|
+
if ((attrs.src ?? "") === "") add("safety.empty-img-src", "warning", `An \`<img>\` renders with an empty \`src\` in the "${state}" state; wrap it in \`{% if <field> != blank %}\` (Liquid treats an empty string as true, so a bare \`{% if <field> %}\` doesn't hide it).`);
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
ontext(text) {
|
|
1579
|
+
if (inStyle) styleText += text;
|
|
1580
|
+
},
|
|
1581
|
+
onclosetag(name) {
|
|
1582
|
+
if (name === "style") {
|
|
1583
|
+
inStyle = false;
|
|
1584
|
+
if (/@import\b/i.test(stripCssComments(styleText))) add("safety.import", "error", IMPORT_MESSAGE);
|
|
1585
|
+
issues.push(...cssLayoutIssues(styleText));
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}, { decodeEntities: true });
|
|
1589
|
+
parser.write(html);
|
|
1590
|
+
parser.end();
|
|
1591
|
+
}
|
|
1592
|
+
if (def.stylesheet) issues.push(...stylesheetIssues(def, def.stylesheet));
|
|
1593
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1594
|
+
return issues.filter((i) => {
|
|
1595
|
+
const k = `${i.ruleId}\0${i.path ?? ""}\0${i.message}`;
|
|
1596
|
+
if (seen.has(k)) return false;
|
|
1597
|
+
seen.add(k);
|
|
1598
|
+
return true;
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
//#endregion
|
|
1602
|
+
//#region src/custom-block/a11y.ts
|
|
1603
|
+
const HEX = /#[0-9a-f]{3,8}\b/i;
|
|
1604
|
+
const PX = /^\s*(\d+(?:\.\d+)?)px/i;
|
|
1605
|
+
/** The colours and font size an element sets inline, over its parent's. */
|
|
1606
|
+
function frameFor(attrs, parent) {
|
|
1607
|
+
const frame = { ...parent };
|
|
1608
|
+
if (attrs.bgcolor && isOpaqueHex(attrs.bgcolor)) frame.background = attrs.bgcolor;
|
|
1609
|
+
for (const [prop, raw] of declarations(attrs.style ?? "")) {
|
|
1610
|
+
const value = raw.replace(/!important/i, "").trim();
|
|
1611
|
+
if (prop === "color" && isOpaqueHex(value)) frame.color = value;
|
|
1612
|
+
if (prop === "background-color" && isOpaqueHex(value)) frame.background = value;
|
|
1613
|
+
if (prop === "background") {
|
|
1614
|
+
const hex = stripCssUrlsAndStrings(value).match(HEX)?.[0];
|
|
1615
|
+
if (hex && isOpaqueHex(hex)) frame.background = hex;
|
|
1616
|
+
}
|
|
1617
|
+
if (prop === "font-size") {
|
|
1618
|
+
const px = value.match(PX)?.[1];
|
|
1619
|
+
if (px) frame.fontSize = Number(px);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return frame;
|
|
1623
|
+
}
|
|
1624
|
+
/** quality's own vague-link rule, run over the HTML as if it were a paragraph. */
|
|
1625
|
+
function vagueLinkMessages(html) {
|
|
1626
|
+
const content = createDefaultTemplateContent();
|
|
1627
|
+
content.blocks = [createParagraphBlock({ content: html })];
|
|
1628
|
+
return lintAccessibility(content).filter((i) => i.ruleId === "a11y.link-vague-text").map((i) => i.message);
|
|
1629
|
+
}
|
|
1630
|
+
function checkAccessibility(rendered) {
|
|
1631
|
+
const issues = [];
|
|
1632
|
+
const add = (ruleId, message) => issues.push({
|
|
1633
|
+
ruleId,
|
|
1634
|
+
severity: "warning",
|
|
1635
|
+
message
|
|
1636
|
+
});
|
|
1637
|
+
for (const { state, html } of rendered) {
|
|
1638
|
+
for (const message of vagueLinkMessages(html)) add("a11y.link-vague-text", message);
|
|
1639
|
+
const frames = [{}];
|
|
1640
|
+
const tables = [];
|
|
1641
|
+
let link = null;
|
|
1642
|
+
const parser = new Parser({
|
|
1643
|
+
onopentag(name, attrs) {
|
|
1644
|
+
frames.push(frameFor(attrs, frames[frames.length - 1]));
|
|
1645
|
+
if (name === "table") {
|
|
1646
|
+
const role = (attrs.role ?? "").toLowerCase();
|
|
1647
|
+
tables.push({
|
|
1648
|
+
presentation: role === "presentation" || role === "none",
|
|
1649
|
+
hasHeader: false
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
if (name === "th" && tables.length > 0) tables[tables.length - 1].hasHeader = true;
|
|
1653
|
+
if (name === "a") link = { named: (attrs["aria-label"] ?? "").trim() !== "" || (attrs.title ?? "").trim() !== "" };
|
|
1654
|
+
if (name === "img" && link && (attrs.alt ?? "").trim() !== "") link.named = true;
|
|
1655
|
+
},
|
|
1656
|
+
ontext(text) {
|
|
1657
|
+
if (text.trim() === "") return;
|
|
1658
|
+
if (link) link.named = true;
|
|
1659
|
+
const { color, background, fontSize } = frames[frames.length - 1];
|
|
1660
|
+
if (!color || !background) return;
|
|
1661
|
+
const required = (fontSize ?? 0) >= 24 ? 3 : 4.5;
|
|
1662
|
+
const ratio = getContrastRatio(color, background);
|
|
1663
|
+
if (Number.isNaN(ratio) || ratio >= required) return;
|
|
1664
|
+
add("a11y.contrast", `Text in ${color} on ${background} has ${ratio.toFixed(2)}:1 contrast; it needs ${required}:1.`);
|
|
1665
|
+
},
|
|
1666
|
+
onclosetag(name) {
|
|
1667
|
+
frames.pop();
|
|
1668
|
+
if (name === "table") {
|
|
1669
|
+
const table = tables.pop();
|
|
1670
|
+
if (table && !table.presentation && !table.hasHeader) add("a11y.table-role", "A layout `<table>` needs `role=\"presentation\"`, or screen readers read it as a data table, cell by cell. A data table needs `<th>` headers instead.");
|
|
1671
|
+
}
|
|
1672
|
+
if (name === "a" && link) {
|
|
1673
|
+
if (!link.named) add("a11y.link-no-name", `A link has no text, \`aria-label\` or image \`alt\` in the "${state}" state, so a screen reader announces only "link".`);
|
|
1674
|
+
link = null;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}, { decodeEntities: true });
|
|
1678
|
+
parser.write(html);
|
|
1679
|
+
parser.end();
|
|
1680
|
+
}
|
|
1681
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1682
|
+
return issues.filter((i) => {
|
|
1683
|
+
const key = `${i.ruleId}\0${i.message}`;
|
|
1684
|
+
if (seen.has(key)) return false;
|
|
1685
|
+
seen.add(key);
|
|
1686
|
+
return true;
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
//#endregion
|
|
1690
|
+
//#region src/custom-block/definition.ts
|
|
1691
|
+
const validateDefinition = new Ajv({
|
|
1692
|
+
allErrors: true,
|
|
1693
|
+
strict: false
|
|
1694
|
+
}).compile({
|
|
1695
|
+
$id: "https://templatical.com/schema/custom-block-definition.json",
|
|
1696
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1697
|
+
$ref: "#/definitions/CustomBlockDefinition",
|
|
1698
|
+
definitions: {
|
|
1699
|
+
"CustomBlockDefinition": {
|
|
1700
|
+
"type": "object",
|
|
1701
|
+
"properties": {
|
|
1702
|
+
"type": { "type": "string" },
|
|
1703
|
+
"name": { "type": "string" },
|
|
1704
|
+
"icon": { "type": "string" },
|
|
1705
|
+
"description": { "type": "string" },
|
|
1706
|
+
"fields": {
|
|
1707
|
+
"type": "array",
|
|
1708
|
+
"items": { "$ref": "#/definitions/CustomBlockField" }
|
|
1709
|
+
},
|
|
1710
|
+
"template": { "type": "string" },
|
|
1711
|
+
"defaultStyles": {
|
|
1712
|
+
"type": "object",
|
|
1713
|
+
"properties": {
|
|
1714
|
+
"padding": { "$ref": "#/definitions/SpacingValue" },
|
|
1715
|
+
"backgroundColor": { "type": "string" }
|
|
1716
|
+
},
|
|
1717
|
+
"additionalProperties": false,
|
|
1718
|
+
"description": "Default block styles applied when a new instance of this custom block is created. Deep-merged over the built-in defaults — only specify the fields you want to override. Controls both the editor canvas wrapper and the rendered MJML/email output."
|
|
1719
|
+
},
|
|
1720
|
+
"stylesheet": {
|
|
1721
|
+
"type": "string",
|
|
1722
|
+
"description": "Optional CSS rules attached to this custom block definition. Emitted once (deduped across instances) into `<mj-head><mj-style>…</mj-style></mj-head>` in the rendered MJML, and adopted into the editor canvas (shadow root or light-DOM mount) so authored responsive/hover/font behavior previews inside the editor.\n\nUse this for media queries, hover states, or any CSS that should apply once per definition rather than per block instance. Class names are not scoped by the SDK — namespace them yourself (e.g. `.tplc-<type>-<el>`) to avoid collisions with other definitions or built-in editor styles."
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
"required": [
|
|
1726
|
+
"type",
|
|
1727
|
+
"name",
|
|
1728
|
+
"fields",
|
|
1729
|
+
"template"
|
|
1730
|
+
],
|
|
1731
|
+
"additionalProperties": false
|
|
1732
|
+
},
|
|
1733
|
+
"CustomBlockField": { "anyOf": [
|
|
1734
|
+
{ "$ref": "#/definitions/CustomBlockTextField" },
|
|
1735
|
+
{ "$ref": "#/definitions/CustomBlockTextareaField" },
|
|
1736
|
+
{ "$ref": "#/definitions/CustomBlockImageField" },
|
|
1737
|
+
{ "$ref": "#/definitions/CustomBlockColorField" },
|
|
1738
|
+
{ "$ref": "#/definitions/CustomBlockNumberField" },
|
|
1739
|
+
{ "$ref": "#/definitions/CustomBlockSelectField" },
|
|
1740
|
+
{ "$ref": "#/definitions/CustomBlockBooleanField" },
|
|
1741
|
+
{ "$ref": "#/definitions/CustomBlockRepeatableField" }
|
|
1742
|
+
] },
|
|
1743
|
+
"CustomBlockTextField": {
|
|
1744
|
+
"type": "object",
|
|
1745
|
+
"properties": {
|
|
1746
|
+
"key": { "type": "string" },
|
|
1747
|
+
"label": { "type": "string" },
|
|
1748
|
+
"required": { "type": "boolean" },
|
|
1749
|
+
"placeholder": { "type": "string" },
|
|
1750
|
+
"readOnly": { "type": "boolean" },
|
|
1751
|
+
"type": {
|
|
1752
|
+
"type": "string",
|
|
1753
|
+
"const": "text"
|
|
1754
|
+
},
|
|
1755
|
+
"default": { "type": "string" }
|
|
1756
|
+
},
|
|
1757
|
+
"required": [
|
|
1758
|
+
"key",
|
|
1759
|
+
"label",
|
|
1760
|
+
"type"
|
|
1761
|
+
],
|
|
1762
|
+
"additionalProperties": false
|
|
1763
|
+
},
|
|
1764
|
+
"CustomBlockTextareaField": {
|
|
1765
|
+
"type": "object",
|
|
1766
|
+
"properties": {
|
|
1767
|
+
"key": { "type": "string" },
|
|
1768
|
+
"label": { "type": "string" },
|
|
1769
|
+
"required": { "type": "boolean" },
|
|
1770
|
+
"placeholder": { "type": "string" },
|
|
1771
|
+
"readOnly": { "type": "boolean" },
|
|
1772
|
+
"type": {
|
|
1773
|
+
"type": "string",
|
|
1774
|
+
"const": "textarea"
|
|
1775
|
+
},
|
|
1776
|
+
"default": { "type": "string" }
|
|
1777
|
+
},
|
|
1778
|
+
"required": [
|
|
1779
|
+
"key",
|
|
1780
|
+
"label",
|
|
1781
|
+
"type"
|
|
1782
|
+
],
|
|
1783
|
+
"additionalProperties": false
|
|
1784
|
+
},
|
|
1785
|
+
"CustomBlockImageField": {
|
|
1786
|
+
"type": "object",
|
|
1787
|
+
"properties": {
|
|
1788
|
+
"key": { "type": "string" },
|
|
1789
|
+
"label": { "type": "string" },
|
|
1790
|
+
"required": { "type": "boolean" },
|
|
1791
|
+
"placeholder": { "type": "string" },
|
|
1792
|
+
"readOnly": { "type": "boolean" },
|
|
1793
|
+
"type": {
|
|
1794
|
+
"type": "string",
|
|
1795
|
+
"const": "image"
|
|
1796
|
+
},
|
|
1797
|
+
"default": { "type": "string" }
|
|
1798
|
+
},
|
|
1799
|
+
"required": [
|
|
1800
|
+
"key",
|
|
1801
|
+
"label",
|
|
1802
|
+
"type"
|
|
1803
|
+
],
|
|
1804
|
+
"additionalProperties": false
|
|
1805
|
+
},
|
|
1806
|
+
"CustomBlockColorField": {
|
|
1807
|
+
"type": "object",
|
|
1808
|
+
"properties": {
|
|
1809
|
+
"presets": {
|
|
1810
|
+
"type": "array",
|
|
1811
|
+
"items": { "type": "string" },
|
|
1812
|
+
"description": "Preset colors rendered as a clickable grid inside every color picker popover. Each entry is a hex string (`'#0b5cff'`); clicking one applies it, and the preset matching the current value is marked selected (matched case-insensitively). The grid supplements the wheel and hex input unless `allowCustom` is `false`."
|
|
1813
|
+
},
|
|
1814
|
+
"allowCustom": {
|
|
1815
|
+
"type": "boolean",
|
|
1816
|
+
"description": "Allow free-form color entry — the wheel and hex input — alongside the presets. Defaults to `true`.\n\nSet to `false` together with `presets` to lock authors to the preset palette: the popover then offers only the preset grid, with no wheel or hex field (a white-label / brand-kit constraint). Ignored with a warning when no `presets` are configured — hiding the only inputs would leave the picker with no way to choose a color."
|
|
1817
|
+
},
|
|
1818
|
+
"key": { "type": "string" },
|
|
1819
|
+
"label": { "type": "string" },
|
|
1820
|
+
"required": { "type": "boolean" },
|
|
1821
|
+
"placeholder": { "type": "string" },
|
|
1822
|
+
"readOnly": { "type": "boolean" },
|
|
1823
|
+
"type": {
|
|
1824
|
+
"type": "string",
|
|
1825
|
+
"const": "color"
|
|
1826
|
+
},
|
|
1827
|
+
"default": { "type": "string" }
|
|
1828
|
+
},
|
|
1829
|
+
"required": [
|
|
1830
|
+
"key",
|
|
1831
|
+
"label",
|
|
1832
|
+
"type"
|
|
1833
|
+
],
|
|
1834
|
+
"additionalProperties": false,
|
|
1835
|
+
"description": "A color-picker field. Beyond the shared field props it accepts the same flat `presets` / `allowCustom` pair as the editor-wide `colors` config (their shapes are derived from `ColorsConfig`, so the two can't drift), scoping this one field to a color *role* — e.g. a button-color field offering only the brand's accent/ink pair while other color fields inherit the global palette.\n\n**Narrowing only — of the author's freedom, not the palette.** A field can restrict what authors may do (lock this field, or give it its own `presets`), but never unlock what the editor locked. Its `presets` *replace* the editor-wide grid rather than intersecting it, so a locked field may offer colors outside `colors.presets`:\n\n- `presets` — entries are validated exactly like editor-level presets (`#rgb` / `#rrggbb` hex; invalid ones are skipped with a console warning naming the block and field). A non-empty valid list replaces the editor's palette for this field; leaving it unset, passing `[]`, or passing only invalid entries all inherit the editor's palette.\n- `allowCustom` — `false` locks this field to its palette even while the rest of the editor allows free-form entry. `true` cannot unlock a field when the editor-wide `colors.allowCustom` is `false`; it is ignored with a warning."
|
|
1836
|
+
},
|
|
1837
|
+
"CustomBlockNumberField": {
|
|
1838
|
+
"type": "object",
|
|
1839
|
+
"properties": {
|
|
1840
|
+
"key": { "type": "string" },
|
|
1841
|
+
"label": { "type": "string" },
|
|
1842
|
+
"required": { "type": "boolean" },
|
|
1843
|
+
"placeholder": { "type": "string" },
|
|
1844
|
+
"readOnly": { "type": "boolean" },
|
|
1845
|
+
"type": {
|
|
1846
|
+
"type": "string",
|
|
1847
|
+
"const": "number"
|
|
1848
|
+
},
|
|
1849
|
+
"default": { "type": "number" },
|
|
1850
|
+
"min": { "type": "number" },
|
|
1851
|
+
"max": { "type": "number" },
|
|
1852
|
+
"step": { "type": "number" }
|
|
1853
|
+
},
|
|
1854
|
+
"required": [
|
|
1855
|
+
"key",
|
|
1856
|
+
"label",
|
|
1857
|
+
"type"
|
|
1858
|
+
],
|
|
1859
|
+
"additionalProperties": false
|
|
1860
|
+
},
|
|
1861
|
+
"CustomBlockSelectField": {
|
|
1862
|
+
"type": "object",
|
|
1863
|
+
"properties": {
|
|
1864
|
+
"key": { "type": "string" },
|
|
1865
|
+
"label": { "type": "string" },
|
|
1866
|
+
"required": { "type": "boolean" },
|
|
1867
|
+
"placeholder": { "type": "string" },
|
|
1868
|
+
"readOnly": { "type": "boolean" },
|
|
1869
|
+
"type": {
|
|
1870
|
+
"type": "string",
|
|
1871
|
+
"const": "select"
|
|
1872
|
+
},
|
|
1873
|
+
"options": {
|
|
1874
|
+
"type": "array",
|
|
1875
|
+
"items": { "$ref": "#/definitions/SelectOption" }
|
|
1876
|
+
},
|
|
1877
|
+
"default": { "type": "string" }
|
|
1878
|
+
},
|
|
1879
|
+
"required": [
|
|
1880
|
+
"key",
|
|
1881
|
+
"label",
|
|
1882
|
+
"options",
|
|
1883
|
+
"type"
|
|
1884
|
+
],
|
|
1885
|
+
"additionalProperties": false
|
|
1886
|
+
},
|
|
1887
|
+
"SelectOption": {
|
|
1888
|
+
"type": "object",
|
|
1889
|
+
"properties": {
|
|
1890
|
+
"label": { "type": "string" },
|
|
1891
|
+
"value": { "type": "string" }
|
|
1892
|
+
},
|
|
1893
|
+
"required": ["label", "value"],
|
|
1894
|
+
"additionalProperties": false
|
|
1895
|
+
},
|
|
1896
|
+
"CustomBlockBooleanField": {
|
|
1897
|
+
"type": "object",
|
|
1898
|
+
"properties": {
|
|
1899
|
+
"key": { "type": "string" },
|
|
1900
|
+
"label": { "type": "string" },
|
|
1901
|
+
"required": { "type": "boolean" },
|
|
1902
|
+
"placeholder": { "type": "string" },
|
|
1903
|
+
"readOnly": { "type": "boolean" },
|
|
1904
|
+
"type": {
|
|
1905
|
+
"type": "string",
|
|
1906
|
+
"const": "boolean"
|
|
1907
|
+
},
|
|
1908
|
+
"default": { "type": "boolean" }
|
|
1909
|
+
},
|
|
1910
|
+
"required": [
|
|
1911
|
+
"key",
|
|
1912
|
+
"label",
|
|
1913
|
+
"type"
|
|
1914
|
+
],
|
|
1915
|
+
"additionalProperties": false
|
|
1916
|
+
},
|
|
1917
|
+
"CustomBlockRepeatableField": {
|
|
1918
|
+
"type": "object",
|
|
1919
|
+
"properties": {
|
|
1920
|
+
"key": { "type": "string" },
|
|
1921
|
+
"label": { "type": "string" },
|
|
1922
|
+
"required": { "type": "boolean" },
|
|
1923
|
+
"placeholder": { "type": "string" },
|
|
1924
|
+
"readOnly": { "type": "boolean" },
|
|
1925
|
+
"type": {
|
|
1926
|
+
"type": "string",
|
|
1927
|
+
"const": "repeatable"
|
|
1928
|
+
},
|
|
1929
|
+
"fields": {
|
|
1930
|
+
"type": "array",
|
|
1931
|
+
"items": { "$ref": "#/definitions/CustomBlockRepeatableItemField" }
|
|
1932
|
+
},
|
|
1933
|
+
"default": {
|
|
1934
|
+
"type": "array",
|
|
1935
|
+
"items": {
|
|
1936
|
+
"type": "object",
|
|
1937
|
+
"additionalProperties": {}
|
|
1938
|
+
}
|
|
1939
|
+
},
|
|
1940
|
+
"minItems": { "type": "number" },
|
|
1941
|
+
"maxItems": { "type": "number" }
|
|
1942
|
+
},
|
|
1943
|
+
"required": [
|
|
1944
|
+
"fields",
|
|
1945
|
+
"key",
|
|
1946
|
+
"label",
|
|
1947
|
+
"type"
|
|
1948
|
+
],
|
|
1949
|
+
"additionalProperties": false
|
|
1950
|
+
},
|
|
1951
|
+
"SpacingValue": {
|
|
1952
|
+
"type": "object",
|
|
1953
|
+
"properties": {
|
|
1954
|
+
"top": { "type": "number" },
|
|
1955
|
+
"right": { "type": "number" },
|
|
1956
|
+
"bottom": { "type": "number" },
|
|
1957
|
+
"left": { "type": "number" }
|
|
1958
|
+
},
|
|
1959
|
+
"required": [
|
|
1960
|
+
"top",
|
|
1961
|
+
"right",
|
|
1962
|
+
"bottom",
|
|
1963
|
+
"left"
|
|
1964
|
+
],
|
|
1965
|
+
"additionalProperties": false
|
|
1966
|
+
},
|
|
1967
|
+
"CustomBlockRepeatableItemField": { "anyOf": [
|
|
1968
|
+
{ "$ref": "#/definitions/CustomBlockTextField" },
|
|
1969
|
+
{ "$ref": "#/definitions/CustomBlockTextareaField" },
|
|
1970
|
+
{ "$ref": "#/definitions/CustomBlockImageField" },
|
|
1971
|
+
{ "$ref": "#/definitions/CustomBlockColorField" },
|
|
1972
|
+
{ "$ref": "#/definitions/CustomBlockNumberField" },
|
|
1973
|
+
{ "$ref": "#/definitions/CustomBlockSelectField" },
|
|
1974
|
+
{ "$ref": "#/definitions/CustomBlockBooleanField" }
|
|
1975
|
+
] }
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
const validateRecipe = new Ajv({
|
|
1979
|
+
allErrors: false,
|
|
1980
|
+
strict: false
|
|
1981
|
+
}).compile({
|
|
1982
|
+
type: "object",
|
|
1983
|
+
additionalProperties: false,
|
|
1984
|
+
required: [
|
|
1985
|
+
"label",
|
|
1986
|
+
"request",
|
|
1987
|
+
"map"
|
|
1988
|
+
],
|
|
1989
|
+
properties: {
|
|
1990
|
+
label: {
|
|
1991
|
+
type: "string",
|
|
1992
|
+
minLength: 1
|
|
1993
|
+
},
|
|
1994
|
+
request: {
|
|
1995
|
+
type: "object",
|
|
1996
|
+
additionalProperties: false,
|
|
1997
|
+
required: ["url"],
|
|
1998
|
+
properties: {
|
|
1999
|
+
method: { enum: ["GET", "POST"] },
|
|
2000
|
+
url: {
|
|
2001
|
+
type: "string",
|
|
2002
|
+
minLength: 1
|
|
2003
|
+
},
|
|
2004
|
+
headers: {
|
|
2005
|
+
type: "object",
|
|
2006
|
+
additionalProperties: { type: "string" }
|
|
2007
|
+
},
|
|
2008
|
+
body: { type: "string" }
|
|
2009
|
+
}
|
|
2010
|
+
},
|
|
2011
|
+
map: {
|
|
2012
|
+
type: "object",
|
|
2013
|
+
additionalProperties: {
|
|
2014
|
+
type: "string",
|
|
2015
|
+
minLength: 1
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
function schemaIssues(errors, base = "") {
|
|
2021
|
+
return (errors ?? []).map((e) => ({
|
|
2022
|
+
ruleId: "schema",
|
|
2023
|
+
severity: "error",
|
|
2024
|
+
path: `${base}${e.instancePath}` || "/",
|
|
2025
|
+
message: `${base}${e.instancePath || ""} ${e.message ?? "is invalid"}${e.keyword === "additionalProperties" ? ` (\`${e.params.additionalProperty}\`)` : ""}`.trim()
|
|
2026
|
+
}));
|
|
2027
|
+
}
|
|
2028
|
+
const isCount = (n) => n === void 0 || Number.isInteger(n) && n >= 0;
|
|
2029
|
+
function fieldIssues(fields, base) {
|
|
2030
|
+
const issues = [];
|
|
2031
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2032
|
+
fields.forEach((field, i) => {
|
|
2033
|
+
const path = `${base}/${i}`;
|
|
2034
|
+
if (seen.has(field.key)) issues.push({
|
|
2035
|
+
ruleId: "field.duplicate-key",
|
|
2036
|
+
severity: "error",
|
|
2037
|
+
path,
|
|
2038
|
+
message: `Field key \`${field.key}\` is used twice; the second would overwrite the first's value.`
|
|
2039
|
+
});
|
|
2040
|
+
seen.add(field.key);
|
|
2041
|
+
if (field.type === "number" && field.min !== void 0 && field.max !== void 0 && field.min > field.max) issues.push({
|
|
2042
|
+
ruleId: "field.range",
|
|
2043
|
+
severity: "error",
|
|
2044
|
+
path,
|
|
2045
|
+
message: `\`${field.key}\` has min ${field.min} above max ${field.max}, so no value is allowed.`
|
|
2046
|
+
});
|
|
2047
|
+
if (field.type === "number" && typeof field.default === "number") {
|
|
2048
|
+
const below = field.min !== void 0 && field.default < field.min;
|
|
2049
|
+
const above = field.max !== void 0 && field.default > field.max;
|
|
2050
|
+
if (below || above) issues.push({
|
|
2051
|
+
ruleId: "field.default-range",
|
|
2052
|
+
severity: "error",
|
|
2053
|
+
path: `${path}/default`,
|
|
2054
|
+
message: `\`${field.key}\` defaults to ${field.default}, outside ${field.min ?? "-∞"}–${field.max ?? "∞"}.`
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
if (field.type === "select" && field.default !== void 0 && !(field.options ?? []).some((o) => o.value === field.default)) issues.push({
|
|
2058
|
+
ruleId: "field.default-option",
|
|
2059
|
+
severity: "error",
|
|
2060
|
+
path: `${path}/default`,
|
|
2061
|
+
message: `\`${field.key}\` defaults to "${String(field.default)}", which is not one of its options.`
|
|
2062
|
+
});
|
|
2063
|
+
if (field.type === "repeatable") {
|
|
2064
|
+
const { minItems, maxItems } = field;
|
|
2065
|
+
if (!isCount(minItems) || !isCount(maxItems)) issues.push({
|
|
2066
|
+
ruleId: "field.items-range",
|
|
2067
|
+
severity: "error",
|
|
2068
|
+
path,
|
|
2069
|
+
message: `\`${field.key}\`'s minItems and maxItems must be whole numbers of 0 or more.`
|
|
2070
|
+
});
|
|
2071
|
+
else if (minItems !== void 0 && maxItems !== void 0 && minItems > maxItems) issues.push({
|
|
2072
|
+
ruleId: "field.items-range",
|
|
2073
|
+
severity: "error",
|
|
2074
|
+
path,
|
|
2075
|
+
message: `\`${field.key}\` has minItems ${minItems} above maxItems ${maxItems}, so no list length is allowed.`
|
|
2076
|
+
});
|
|
2077
|
+
else if (Array.isArray(field.default)) {
|
|
2078
|
+
const n = field.default.length;
|
|
2079
|
+
if (minItems !== void 0 && n < minItems || maxItems !== void 0 && n > maxItems) issues.push({
|
|
2080
|
+
ruleId: "field.default-items",
|
|
2081
|
+
severity: "error",
|
|
2082
|
+
path: `${path}/default`,
|
|
2083
|
+
message: `\`${field.key}\` defaults to ${n} item(s), outside ${minItems ?? 0}–${maxItems ?? "∞"}; the palette would insert a list the editor won't allow.`
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
if (field.fields) issues.push(...fieldIssues(field.fields, `${path}/fields`));
|
|
2087
|
+
}
|
|
2088
|
+
});
|
|
2089
|
+
return issues;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* A module's `dataSource` is `{ label, onFetch }` with a function, which the
|
|
2093
|
+
* schema can't describe (the generator hides functions). A JSON working file
|
|
2094
|
+
* can't hold a function at all, so there it is always an error that points at
|
|
2095
|
+
* `dataSourcePreview`.
|
|
2096
|
+
*/
|
|
2097
|
+
function dataSourceIssues(dataSource) {
|
|
2098
|
+
const ds = dataSource;
|
|
2099
|
+
const issue = (message) => [{
|
|
2100
|
+
ruleId: "schema",
|
|
2101
|
+
severity: "error",
|
|
2102
|
+
path: "/dataSource",
|
|
2103
|
+
message
|
|
2104
|
+
}];
|
|
2105
|
+
if (ds === null || typeof ds !== "object" || Array.isArray(ds)) return issue("`dataSource` must be an object with `label` and `onFetch`.");
|
|
2106
|
+
if (typeof ds.onFetch !== "function") return issue("`dataSource.onFetch` must be a function. A JSON working file can't hold one: preview with `dataSourcePreview`, and write `dataSource` in the module at handoff.");
|
|
2107
|
+
if (typeof ds.label !== "string" || ds.label === "") return issue("`dataSource.label` must be a non-empty string.");
|
|
2108
|
+
return [];
|
|
2109
|
+
}
|
|
2110
|
+
function validateCustomBlockDefinition(data) {
|
|
2111
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return {
|
|
2112
|
+
valid: false,
|
|
2113
|
+
issues: [{
|
|
2114
|
+
ruleId: "schema",
|
|
2115
|
+
severity: "error",
|
|
2116
|
+
path: "/",
|
|
2117
|
+
message: "A custom block definition must be a JSON object."
|
|
2118
|
+
}]
|
|
2119
|
+
};
|
|
2120
|
+
const { dataSourcePreview, dataSource, ...definition } = data;
|
|
2121
|
+
const issues = [];
|
|
2122
|
+
if (dataSource !== void 0) issues.push(...dataSourceIssues(dataSource));
|
|
2123
|
+
if (!validateDefinition(definition)) issues.push(...schemaIssues(validateDefinition.errors));
|
|
2124
|
+
if (dataSourcePreview !== void 0 && !validateRecipe(dataSourcePreview)) issues.push(...schemaIssues(validateRecipe.errors, "/dataSourcePreview"));
|
|
2125
|
+
if (issues.length === 0) issues.push(...fieldIssues(definition.fields, "/fields"));
|
|
2126
|
+
return {
|
|
2127
|
+
valid: !issues.some((i) => i.severity === "error"),
|
|
2128
|
+
issues
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
//#endregion
|
|
2132
|
+
//#region src/custom-block/liquid.ts
|
|
2133
|
+
function createLiquid() {
|
|
2134
|
+
return new Liquid({
|
|
2135
|
+
strictVariables: false,
|
|
2136
|
+
strictFilters: false,
|
|
2137
|
+
templates: {}
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
const LIQUID_GLOBALS = /* @__PURE__ */ new Set(["forloop"]);
|
|
2141
|
+
const FILE_TAGS = /* @__PURE__ */ new Set([
|
|
2142
|
+
"include",
|
|
2143
|
+
"render",
|
|
2144
|
+
"layout"
|
|
2145
|
+
]);
|
|
2146
|
+
const NO_PARTIALS = { partials: false };
|
|
2147
|
+
/** A tag's child templates, from liquidjs's static-analysis generator. */
|
|
2148
|
+
function childTemplates(template) {
|
|
2149
|
+
if (!template.children) return [];
|
|
2150
|
+
const gen = template.children(false, true);
|
|
2151
|
+
let step = gen.next();
|
|
2152
|
+
while (!step.done) step = gen.next();
|
|
2153
|
+
return step.value ?? [];
|
|
2154
|
+
}
|
|
2155
|
+
/** Every template in the tree, depth-first, nested tags included. */
|
|
2156
|
+
function allTemplates(templates) {
|
|
2157
|
+
return templates.flatMap((t) => [t, ...allTemplates(childTemplates(t))]);
|
|
2158
|
+
}
|
|
2159
|
+
/** Every `for` tag in the tree, nested ones included. */
|
|
2160
|
+
function forTags(templates) {
|
|
2161
|
+
return allTemplates(templates).filter((t) => t instanceof ForTag);
|
|
2162
|
+
}
|
|
2163
|
+
/** Names of the file-loading tags in the tree, each once, in source order. */
|
|
2164
|
+
function fileTags(templates) {
|
|
2165
|
+
const names = allTemplates(templates).filter((t) => t instanceof Tag && FILE_TAGS.has(t.name)).map((t) => t.name);
|
|
2166
|
+
return [...new Set(names)];
|
|
2167
|
+
}
|
|
2168
|
+
/** How many `{{ }}` outputs in the tree reach the text without a `json` filter. */
|
|
2169
|
+
function outputsWithoutJson(templates) {
|
|
2170
|
+
return allTemplates(templates).filter((t) => t instanceof Output && !t.value.filters.some((f) => f.name === "json")).length;
|
|
2171
|
+
}
|
|
2172
|
+
/** The message for a template or recipe that uses a file-loading tag. */
|
|
2173
|
+
function fileTagMessage(name, where) {
|
|
2174
|
+
return `\`{% ${name} %}\` loads another template file, and ${where} has none to load. Write its content inline instead.`;
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* The field a loop iterates, when its collection is a bare identifier.
|
|
2178
|
+
* `for x in a.b` iterates a property of `a`, not `a`, so it has none.
|
|
2179
|
+
*/
|
|
2180
|
+
function bareCollection(tag) {
|
|
2181
|
+
const c = tag.collection;
|
|
2182
|
+
if (c.variable !== void 0 || c.props?.length !== 1) return null;
|
|
2183
|
+
const name = c.props[0].content;
|
|
2184
|
+
return typeof name === "string" ? name : null;
|
|
2185
|
+
}
|
|
2186
|
+
function checkLiquid(def) {
|
|
2187
|
+
const engine = createLiquid();
|
|
2188
|
+
let parsed;
|
|
2189
|
+
try {
|
|
2190
|
+
parsed = engine.parse(def.template);
|
|
2191
|
+
} catch (err) {
|
|
2192
|
+
return [{
|
|
2193
|
+
ruleId: "liquid.parse",
|
|
2194
|
+
severity: "error",
|
|
2195
|
+
path: "/template",
|
|
2196
|
+
message: `The template does not parse: ${err.message}`
|
|
2197
|
+
}];
|
|
2198
|
+
}
|
|
2199
|
+
const issues = fileTags(parsed).map((name) => ({
|
|
2200
|
+
ruleId: "liquid.file-tag",
|
|
2201
|
+
severity: "error",
|
|
2202
|
+
path: "/template",
|
|
2203
|
+
message: fileTagMessage(name, "a custom block")
|
|
2204
|
+
}));
|
|
2205
|
+
const fields = new Map(def.fields.map((f, i) => [f.key, {
|
|
2206
|
+
field: f,
|
|
2207
|
+
index: i
|
|
2208
|
+
}]));
|
|
2209
|
+
const globals = new Set(engine.globalVariableSegmentsSync(def.template, NO_PARTIALS).map((segments) => String(segments[0])).filter((name) => !LIQUID_GLOBALS.has(name)));
|
|
2210
|
+
for (const name of globals) if (!fields.has(name)) issues.push({
|
|
2211
|
+
ruleId: "liquid.undefined-variable",
|
|
2212
|
+
severity: "error",
|
|
2213
|
+
path: "/template",
|
|
2214
|
+
message: `The template reads \`${name}\`, which no field defines.`
|
|
2215
|
+
});
|
|
2216
|
+
const recipeReads = /* @__PURE__ */ new Set();
|
|
2217
|
+
const request = def.dataSourcePreview?.request;
|
|
2218
|
+
for (const text of [request?.url, request?.body]) {
|
|
2219
|
+
if (text === void 0) continue;
|
|
2220
|
+
try {
|
|
2221
|
+
for (const [name] of engine.globalVariableSegmentsSync(text, NO_PARTIALS)) recipeReads.add(String(name));
|
|
2222
|
+
} catch {}
|
|
2223
|
+
}
|
|
2224
|
+
for (const [key, { index }] of fields) if (!globals.has(key) && !recipeReads.has(key)) issues.push({
|
|
2225
|
+
ruleId: "liquid.unused-field",
|
|
2226
|
+
severity: "warning",
|
|
2227
|
+
path: `/fields/${index}`,
|
|
2228
|
+
message: `Field \`${key}\` is never read by the template or the data-source recipe.`
|
|
2229
|
+
});
|
|
2230
|
+
for (const loop of forTags(parsed)) {
|
|
2231
|
+
const local = loop.variable;
|
|
2232
|
+
const collection = bareCollection(loop);
|
|
2233
|
+
if (collection === null) continue;
|
|
2234
|
+
const target = fields.get(collection)?.field;
|
|
2235
|
+
if (!target) continue;
|
|
2236
|
+
if (target.type !== "repeatable") {
|
|
2237
|
+
issues.push({
|
|
2238
|
+
ruleId: "liquid.loop-not-repeatable",
|
|
2239
|
+
severity: "error",
|
|
2240
|
+
path: "/template",
|
|
2241
|
+
message: `\`{% for ${local} in ${collection} %}\` iterates \`${collection}\`, which is a ${target.type} field, not a repeatable.`
|
|
2242
|
+
});
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
for (const [head, sub] of engine.variableSegmentsSync(loop.templates, NO_PARTIALS)) {
|
|
2246
|
+
if (String(head) !== local || typeof sub !== "string") continue;
|
|
2247
|
+
if (!target.fields.some((f) => f.key === sub)) issues.push({
|
|
2248
|
+
ruleId: "liquid.unknown-item-key",
|
|
2249
|
+
severity: "error",
|
|
2250
|
+
path: "/template",
|
|
2251
|
+
message: `\`${local}.${sub}\` reads \`${sub}\`, which \`${collection}\`'s items don't define.`
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
return issues;
|
|
2256
|
+
}
|
|
2257
|
+
//#endregion
|
|
2258
|
+
//#region src/custom-block/recipe.ts
|
|
2259
|
+
const ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
2260
|
+
const LIQUID = /\{\{|\{%/;
|
|
2261
|
+
const UNSAFE_KEYS = /* @__PURE__ */ new Set([
|
|
2262
|
+
"__proto__",
|
|
2263
|
+
"constructor",
|
|
2264
|
+
"prototype"
|
|
2265
|
+
]);
|
|
2266
|
+
const CREDENTIAL_WORD = /^(?:authorization|auth|cookie|key|apikey|password|credentials?|sessionid)$|(?:token|secret)$/;
|
|
2267
|
+
function namesCredential(header) {
|
|
2268
|
+
const words = header.toLowerCase().split(/[-_]/);
|
|
2269
|
+
return words.some((w, i) => CREDENTIAL_WORD.test(w) || w === "session" && words[i + 1] === "id");
|
|
2270
|
+
}
|
|
2271
|
+
const HOST_TEMPLATE_ERROR = "The request URL's scheme and host must be literal; Liquid may only follow the first `/` after the host.";
|
|
2272
|
+
/**
|
|
2273
|
+
* Where the URL's scheme and authority end: the first `/`, `?` or `#` after
|
|
2274
|
+
* `://`, or the whole string when there is no `://`. Anything before this
|
|
2275
|
+
* index decides the origin, so it must hold no Liquid.
|
|
2276
|
+
*/
|
|
2277
|
+
function authorityEnd(url) {
|
|
2278
|
+
const scheme = url.indexOf("://");
|
|
2279
|
+
if (scheme < 0) return url.length;
|
|
2280
|
+
const rest = url.slice(scheme + 3).search(/[/?#]/);
|
|
2281
|
+
return rest < 0 ? url.length : scheme + 3 + rest;
|
|
2282
|
+
}
|
|
2283
|
+
/** The recipe URL's origin, or null when Liquid can reach it. */
|
|
2284
|
+
function literalOrigin(url) {
|
|
2285
|
+
const prefix = url.slice(0, authorityEnd(url));
|
|
2286
|
+
if (LIQUID.test(prefix)) return null;
|
|
2287
|
+
try {
|
|
2288
|
+
return new URL(prefix).origin;
|
|
2289
|
+
} catch {
|
|
2290
|
+
return null;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
function encodeValue(value) {
|
|
2294
|
+
if (value === null || value === void 0) return "";
|
|
2295
|
+
if (typeof value === "string" || typeof value === "number") return encodeURIComponent(String(value));
|
|
2296
|
+
return encodeURIComponent(JSON.stringify(value));
|
|
2297
|
+
}
|
|
2298
|
+
function getPath(value, path) {
|
|
2299
|
+
const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
|
|
2300
|
+
let cur = value;
|
|
2301
|
+
for (const part of parts) {
|
|
2302
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
2303
|
+
cur = cur[part];
|
|
2304
|
+
}
|
|
2305
|
+
return cur;
|
|
2306
|
+
}
|
|
2307
|
+
function checkRecipe(def) {
|
|
2308
|
+
const r = def.dataSourcePreview;
|
|
2309
|
+
if (!r) return [];
|
|
2310
|
+
const issues = [];
|
|
2311
|
+
const keys = new Set(def.fields.map((f) => f.key));
|
|
2312
|
+
const add = (severity) => (ruleId, path, message) => issues.push({
|
|
2313
|
+
ruleId,
|
|
2314
|
+
severity,
|
|
2315
|
+
path: `/dataSourcePreview${path}`,
|
|
2316
|
+
message
|
|
2317
|
+
});
|
|
2318
|
+
const err = add("error");
|
|
2319
|
+
const warn = add("warning");
|
|
2320
|
+
const engine = createLiquid();
|
|
2321
|
+
for (const [part, text] of [["/request/url", r.request.url], ["/request/body", r.request.body]]) {
|
|
2322
|
+
if (text === void 0) continue;
|
|
2323
|
+
if (new RegExp(ENV_REF.source).test(text)) err("recipe.env-outside-headers", part, "`${env:…}` is resolved only in header values; keep secrets out of the URL and body.");
|
|
2324
|
+
try {
|
|
2325
|
+
for (const [name] of engine.globalVariableSegmentsSync(text, NO_PARTIALS)) if (!keys.has(String(name))) err("recipe.undefined-variable", part, `The recipe reads \`${String(name)}\`, which no field defines.`);
|
|
2326
|
+
const parsed = engine.parse(text);
|
|
2327
|
+
for (const name of fileTags(parsed)) err("recipe.file-tag", part, fileTagMessage(name, "a recipe"));
|
|
2328
|
+
if (part === "/request/body" && /^\s*[[{]/.test(text) && outputsWithoutJson(parsed) > 0) warn("recipe.body-unescaped", part, "A JSON body interpolates a value without `| json`, so a quote in it breaks the JSON. Write `{{ key | json }}`, which adds the quotes.");
|
|
2329
|
+
} catch (e) {
|
|
2330
|
+
err("recipe.undefined-variable", part, `Does not parse: ${e.message}`);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
if (r.request.url.includes("://") && LIQUID.test(r.request.url.slice(0, authorityEnd(r.request.url)))) err("recipe.url-host-template", "/request/url", HOST_TEMPLATE_ERROR);
|
|
2334
|
+
const probeUrl = replaceLiquid(r.request.url);
|
|
2335
|
+
let parses = true;
|
|
2336
|
+
try {
|
|
2337
|
+
new URL(probeUrl);
|
|
2338
|
+
} catch {
|
|
2339
|
+
parses = false;
|
|
2340
|
+
}
|
|
2341
|
+
if (!/^https?:\/\//i.test(probeUrl) || !parses) err("recipe.url", "/request/url", "The request URL must be an absolute http(s) URL.");
|
|
2342
|
+
if (r.request.body !== void 0 && (r.request.method ?? "GET") !== "POST") err("recipe.body-without-post", "/request/body", "A `body` is sent only with `\"method\": \"POST\"`; a GET request can't carry one.");
|
|
2343
|
+
for (const [name, value] of Object.entries(r.request.headers ?? {})) if (LIQUID.test(value)) err("recipe.field-in-header", `/request/headers/${name}`, `Header \`${name}\` interpolates a field; headers take only \`\${env:NAME}\`.`);
|
|
2344
|
+
else if (namesCredential(name) && !new RegExp(ENV_REF.source).test(value)) err("recipe.literal-secret", `/request/headers/${name}`, `Header \`${name}\` holds a literal credential. Use \`\${env:NAME}\` and export the variable instead.`);
|
|
2345
|
+
for (const field of Object.keys(r.map)) if (!keys.has(field)) err("recipe.unknown-map-key", `/map/${field}`, `\`map\` writes \`${field}\`, which no field defines.`);
|
|
2346
|
+
return issues;
|
|
2347
|
+
}
|
|
2348
|
+
async function runRecipe(recipe, fieldValues, { env = process.env, fetch: doFetch = fetch, timeoutMs = 1e4 } = {}) {
|
|
2349
|
+
const expectedOrigin = literalOrigin(recipe.request.url);
|
|
2350
|
+
if (expectedOrigin === null) return {
|
|
2351
|
+
ok: false,
|
|
2352
|
+
error: HOST_TEMPLATE_ERROR
|
|
2353
|
+
};
|
|
2354
|
+
const engine = createLiquid();
|
|
2355
|
+
const encoded = Object.fromEntries(Object.entries(fieldValues).map(([k, v]) => [k, encodeValue(v)]));
|
|
2356
|
+
let url;
|
|
2357
|
+
let body;
|
|
2358
|
+
try {
|
|
2359
|
+
url = String(await engine.parseAndRender(recipe.request.url, encoded));
|
|
2360
|
+
body = recipe.request.body === void 0 ? void 0 : String(await engine.parseAndRender(recipe.request.body, fieldValues));
|
|
2361
|
+
} catch (e) {
|
|
2362
|
+
return {
|
|
2363
|
+
ok: false,
|
|
2364
|
+
error: `The request template does not render: ${e.message}`
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
let renderedOrigin = null;
|
|
2368
|
+
try {
|
|
2369
|
+
renderedOrigin = new URL(url).origin;
|
|
2370
|
+
} catch {}
|
|
2371
|
+
/* v8 ignore start */
|
|
2372
|
+
if (renderedOrigin !== expectedOrigin) return {
|
|
2373
|
+
ok: false,
|
|
2374
|
+
error: `The rendered URL's origin (${renderedOrigin ?? "unparseable"}) differs from the recipe's (${expectedOrigin}); no request was made.`
|
|
2375
|
+
};
|
|
2376
|
+
/* v8 ignore stop */
|
|
2377
|
+
const secrets = [];
|
|
2378
|
+
const headers = {};
|
|
2379
|
+
for (const [name, template] of Object.entries(recipe.request.headers ?? {})) {
|
|
2380
|
+
let missing;
|
|
2381
|
+
headers[name] = template.replace(ENV_REF, (_, variable) => {
|
|
2382
|
+
const value = env[variable];
|
|
2383
|
+
if (value === void 0 || value === "") {
|
|
2384
|
+
missing ??= variable;
|
|
2385
|
+
return "";
|
|
2386
|
+
}
|
|
2387
|
+
secrets.push(value);
|
|
2388
|
+
return value;
|
|
2389
|
+
});
|
|
2390
|
+
if (missing) return {
|
|
2391
|
+
ok: false,
|
|
2392
|
+
error: `Environment variable ${missing} is not set. Export it in the shell that runs the live server.`
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
const redact = (s) => secrets.reduce((acc, secret) => acc.split(secret).join("***"), s);
|
|
2396
|
+
const redactDeep = (v) => {
|
|
2397
|
+
if (typeof v === "string") return redact(v);
|
|
2398
|
+
if (Array.isArray(v)) return v.map(redactDeep);
|
|
2399
|
+
if (v !== null && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redactDeep(x)]));
|
|
2400
|
+
return v;
|
|
2401
|
+
};
|
|
2402
|
+
const method = recipe.request.method ?? "GET";
|
|
2403
|
+
try {
|
|
2404
|
+
const res = await doFetch(url, {
|
|
2405
|
+
method,
|
|
2406
|
+
headers,
|
|
2407
|
+
body,
|
|
2408
|
+
redirect: "manual",
|
|
2409
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2410
|
+
});
|
|
2411
|
+
if (res.status >= 300 && res.status < 400) {
|
|
2412
|
+
const location = res.headers.get("location") ?? "(none)";
|
|
2413
|
+
return {
|
|
2414
|
+
ok: false,
|
|
2415
|
+
status: res.status,
|
|
2416
|
+
error: redact(`${method} ${url} redirected (${res.status}) to ${location}. Redirects are not followed; point the recipe at the final URL.`)
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
const text = await res.text();
|
|
2420
|
+
if (!res.ok) return {
|
|
2421
|
+
ok: false,
|
|
2422
|
+
status: res.status,
|
|
2423
|
+
error: `${redact(`${method} ${url} returned ${res.status}: `)}${redact(text).slice(0, 200)}`
|
|
2424
|
+
};
|
|
2425
|
+
let json;
|
|
2426
|
+
try {
|
|
2427
|
+
json = JSON.parse(text);
|
|
2428
|
+
} catch {
|
|
2429
|
+
return {
|
|
2430
|
+
ok: false,
|
|
2431
|
+
status: res.status,
|
|
2432
|
+
error: redact(`${method} ${url} did not return JSON.`)
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
const values = {};
|
|
2436
|
+
const unmapped = [];
|
|
2437
|
+
for (const [field, path] of Object.entries(recipe.map)) {
|
|
2438
|
+
if (UNSAFE_KEYS.has(field)) continue;
|
|
2439
|
+
const v = getPath(json, path);
|
|
2440
|
+
if (v === void 0) unmapped.push(field);
|
|
2441
|
+
else values[field] = redactDeep(v);
|
|
2442
|
+
}
|
|
2443
|
+
return {
|
|
2444
|
+
ok: true,
|
|
2445
|
+
status: res.status,
|
|
2446
|
+
values,
|
|
2447
|
+
unmapped
|
|
2448
|
+
};
|
|
2449
|
+
} catch (e) {
|
|
2450
|
+
return {
|
|
2451
|
+
ok: false,
|
|
2452
|
+
error: redact(`${method} ${url} failed: ${e.message}`)
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
/** Replaces each `{{…}}` / `{%…%}` span with "x" in one linear pass; an unterminated opener swallows the rest. */
|
|
2457
|
+
function replaceLiquid(text) {
|
|
2458
|
+
const MISSING = Infinity;
|
|
2459
|
+
const next = (needle, from) => {
|
|
2460
|
+
const at = text.indexOf(needle, from);
|
|
2461
|
+
return at === -1 ? MISSING : at;
|
|
2462
|
+
};
|
|
2463
|
+
let out = "";
|
|
2464
|
+
let i = 0;
|
|
2465
|
+
let a = next("{{", 0);
|
|
2466
|
+
let b = next("{%", 0);
|
|
2467
|
+
for (;;) {
|
|
2468
|
+
if (a < i) a = next("{{", i);
|
|
2469
|
+
if (b < i) b = next("{%", i);
|
|
2470
|
+
const open = Math.min(a, b);
|
|
2471
|
+
if (open === MISSING) return out + text.slice(i);
|
|
2472
|
+
out += text.slice(i, open);
|
|
2473
|
+
const close = text.indexOf(open === a ? "}}" : "%}", open + 2);
|
|
2474
|
+
if (close === -1) return out + "x";
|
|
2475
|
+
out += "x";
|
|
2476
|
+
i = close + 2;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
//#endregion
|
|
2480
|
+
//#region src/custom-block/specimen.ts
|
|
2481
|
+
const SPECIMEN_STATES = [
|
|
2482
|
+
"defaults",
|
|
2483
|
+
"empty",
|
|
2484
|
+
"long",
|
|
2485
|
+
"min-items",
|
|
2486
|
+
"max-items",
|
|
2487
|
+
"flipped"
|
|
2488
|
+
];
|
|
2489
|
+
function fieldDefault(f) {
|
|
2490
|
+
if (f.default !== void 0) return structuredClone(f.default);
|
|
2491
|
+
if (f.type === "repeatable") return [];
|
|
2492
|
+
if (f.type === "boolean") return false;
|
|
2493
|
+
if (f.type === "number") return 0;
|
|
2494
|
+
return "";
|
|
2495
|
+
}
|
|
2496
|
+
function itemFrom(fields) {
|
|
2497
|
+
return Object.fromEntries(fields.map((f) => [f.key, fieldDefault(f)]));
|
|
2498
|
+
}
|
|
2499
|
+
function items(f, n) {
|
|
2500
|
+
const seeds = Array.isArray(f.default) && f.default.length > 0 ? f.default : [itemFrom(f.fields)];
|
|
2501
|
+
return Array.from({ length: n }, (_, i) => structuredClone(seeds[i % seeds.length]));
|
|
2502
|
+
}
|
|
2503
|
+
function lengthen(f, v) {
|
|
2504
|
+
if (f.type === "text" || f.type === "textarea") {
|
|
2505
|
+
const base = typeof v === "string" && v || f.placeholder || f.label;
|
|
2506
|
+
return [
|
|
2507
|
+
base,
|
|
2508
|
+
base,
|
|
2509
|
+
base
|
|
2510
|
+
].join(" ");
|
|
2511
|
+
}
|
|
2512
|
+
if (f.type === "number") return f.max ?? v;
|
|
2513
|
+
if (f.type === "repeatable" && Array.isArray(v)) return v.map((item) => Object.fromEntries(f.fields.map((s) => [s.key, lengthen(s, item[s.key])])));
|
|
2514
|
+
return v;
|
|
2515
|
+
}
|
|
2516
|
+
function clear(f, v) {
|
|
2517
|
+
if (f.required) return v;
|
|
2518
|
+
if (f.type === "boolean") return false;
|
|
2519
|
+
if (f.type === "number") return null;
|
|
2520
|
+
if (f.type === "repeatable") return items(f, f.minItems ?? 0);
|
|
2521
|
+
return "";
|
|
2522
|
+
}
|
|
2523
|
+
function buildSpecimen(def) {
|
|
2524
|
+
const defaults = createCustomBlock(def).fieldValues;
|
|
2525
|
+
const map = (fn) => Object.fromEntries(def.fields.map((f) => [f.key, fn(f, structuredClone(defaults[f.key]))]));
|
|
2526
|
+
const out = [
|
|
2527
|
+
{
|
|
2528
|
+
state: "defaults",
|
|
2529
|
+
fieldValues: structuredClone(defaults)
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
state: "empty",
|
|
2533
|
+
fieldValues: map(clear)
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
state: "long",
|
|
2537
|
+
fieldValues: map(lengthen)
|
|
2538
|
+
}
|
|
2539
|
+
];
|
|
2540
|
+
const repeatables = def.fields.filter((f) => f.type === "repeatable");
|
|
2541
|
+
if (repeatables.length > 0) {
|
|
2542
|
+
const sized = (pick) => ({
|
|
2543
|
+
...structuredClone(defaults),
|
|
2544
|
+
...Object.fromEntries(repeatables.map((f) => [f.key, items(f, pick(f))]))
|
|
2545
|
+
});
|
|
2546
|
+
out.push({
|
|
2547
|
+
state: "min-items",
|
|
2548
|
+
fieldValues: sized((f) => f.minItems ?? 0)
|
|
2549
|
+
});
|
|
2550
|
+
out.push({
|
|
2551
|
+
state: "max-items",
|
|
2552
|
+
fieldValues: sized((f) => Math.max(f.minItems ?? 0, f.maxItems ?? 6))
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
const booleans = def.fields.filter((f) => f.type === "boolean");
|
|
2556
|
+
if (booleans.length > 0) out.push({
|
|
2557
|
+
state: "flipped",
|
|
2558
|
+
fieldValues: {
|
|
2559
|
+
...structuredClone(defaults),
|
|
2560
|
+
...Object.fromEntries(booleans.map((f) => [f.key, !defaults[f.key]]))
|
|
2561
|
+
}
|
|
2562
|
+
});
|
|
2563
|
+
return out;
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* The live preview's "fetched" state: the block filled from the real
|
|
2567
|
+
* endpoint and marked fetched, so read-only fields lock as they do in
|
|
2568
|
+
* production. A failed request puts its error on the label instead.
|
|
2569
|
+
*/
|
|
2570
|
+
function fetchedSpecimenBlocks(def, fetched) {
|
|
2571
|
+
const id = "specimen-fetched";
|
|
2572
|
+
if (!fetched.ok) return [createParagraphBlock({
|
|
2573
|
+
id: `${id}-label`,
|
|
2574
|
+
content: `<p><strong>Specimen:</strong> fetched. The request failed: ${escapeHtml(fetched.error)}</p>`
|
|
2575
|
+
})];
|
|
2576
|
+
return [createParagraphBlock({
|
|
2577
|
+
id: `${id}-label`,
|
|
2578
|
+
content: "<p><strong>Specimen:</strong> fetched</p>"
|
|
2579
|
+
}), {
|
|
2580
|
+
...createCustomBlock(def),
|
|
2581
|
+
id,
|
|
2582
|
+
fieldValues: fetched.fieldValues,
|
|
2583
|
+
dataSourceFetched: true
|
|
2584
|
+
}];
|
|
2585
|
+
}
|
|
2586
|
+
/** The id each state's custom block gets in the specimen sheet; its label is `${id}-label`. */
|
|
2587
|
+
function specimenBlockId(state) {
|
|
2588
|
+
return `specimen-${state}`;
|
|
2589
|
+
}
|
|
2590
|
+
function buildSpecimenTemplate(def, host, only) {
|
|
2591
|
+
const content = host ? structuredClone(host) : createDefaultTemplateContent();
|
|
2592
|
+
for (const instance of buildSpecimen(def)) {
|
|
2593
|
+
if (only && instance.state !== only) continue;
|
|
2594
|
+
const id = specimenBlockId(instance.state);
|
|
2595
|
+
content.blocks.push(createParagraphBlock({
|
|
2596
|
+
id: `${id}-label`,
|
|
2597
|
+
content: `<p><strong>Specimen:</strong> ${instance.state}</p>`
|
|
2598
|
+
}), {
|
|
2599
|
+
...createCustomBlock(def),
|
|
2600
|
+
id,
|
|
2601
|
+
fieldValues: instance.fieldValues
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
return content;
|
|
2605
|
+
}
|
|
2606
|
+
//#endregion
|
|
2607
|
+
//#region src/custom-block/render.ts
|
|
2608
|
+
async function renderStates(def, instances = buildSpecimen(def)) {
|
|
2609
|
+
const liquid = createLiquid();
|
|
2610
|
+
return Promise.all(instances.map(async (i) => ({
|
|
2611
|
+
state: i.state,
|
|
2612
|
+
html: String(await liquid.parseAndRender(def.template, i.fieldValues))
|
|
2613
|
+
})));
|
|
2614
|
+
}
|
|
2615
|
+
async function renderSpecimenMjml(def, template = buildSpecimenTemplate(def)) {
|
|
2616
|
+
const liquid = createLiquid();
|
|
2617
|
+
return renderToMjml(template, {
|
|
2618
|
+
renderCustomBlock: async (block) => String(await liquid.parseAndRender(def.template, block.fieldValues)),
|
|
2619
|
+
getCustomBlockStylesheet: (customType) => customType === def.type ? def.stylesheet : void 0
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
//#endregion
|
|
2623
|
+
//#region src/custom-block/check.ts
|
|
2624
|
+
const finish = (issues) => ({
|
|
2625
|
+
valid: !issues.some((i) => i.severity === "error"),
|
|
2626
|
+
issues
|
|
2627
|
+
});
|
|
2628
|
+
async function checkCustomBlock(data, { compileMjml } = {}) {
|
|
2629
|
+
const structural = validateCustomBlockDefinition(data);
|
|
2630
|
+
if (!structural.valid) return structural;
|
|
2631
|
+
const def = data;
|
|
2632
|
+
const issues = [...structural.issues, ...checkRecipe(def)];
|
|
2633
|
+
const liquid = checkLiquid(def);
|
|
2634
|
+
issues.push(...liquid);
|
|
2635
|
+
if (liquid.some((i) => i.ruleId === "liquid.parse" || i.ruleId === "liquid.file-tag")) return finish(issues);
|
|
2636
|
+
let rendered;
|
|
2637
|
+
try {
|
|
2638
|
+
rendered = await renderStates(def);
|
|
2639
|
+
} catch (err) {
|
|
2640
|
+
issues.push({
|
|
2641
|
+
ruleId: "liquid.render",
|
|
2642
|
+
severity: "error",
|
|
2643
|
+
path: "/template",
|
|
2644
|
+
message: `The template does not render: ${err.message}`
|
|
2645
|
+
});
|
|
2646
|
+
return finish(issues);
|
|
2647
|
+
}
|
|
2648
|
+
issues.push(...checkEmailSafety(def, rendered));
|
|
2649
|
+
issues.push(...checkAccessibility(rendered));
|
|
2650
|
+
const mjml = await renderSpecimenMjml(def);
|
|
2651
|
+
const stylesheet = def.stylesheet?.trim();
|
|
2652
|
+
if (stylesheet && !mjml.includes(stylesheet)) issues.push({
|
|
2653
|
+
ruleId: "render.stylesheet-missing",
|
|
2654
|
+
severity: "error",
|
|
2655
|
+
message: "The stylesheet did not reach <mj-head> in the rendered MJML."
|
|
2656
|
+
});
|
|
2657
|
+
if (compileMjml) for (const e of (await compileMjml(mjml)).errors) {
|
|
2658
|
+
const o = e;
|
|
2659
|
+
issues.push({
|
|
2660
|
+
ruleId: "render.mjml",
|
|
2661
|
+
severity: "warning",
|
|
2662
|
+
message: o.formattedMessage ?? o.message ?? String(e)
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
return finish(issues);
|
|
2666
|
+
}
|
|
2667
|
+
//#endregion
|
|
2668
|
+
export { buildSpecimenTemplate as a, runRecipe as c, schema as d, validateTemplate as f, buildSpecimen as i, validateCustomBlockDefinition as l, renderSpecimenMjml as n, fetchedSpecimenBlocks as o, SPECIMEN_STATES as r, checkRecipe as s, checkCustomBlock as t, runQualityLint as u };
|
|
2669
|
+
|
|
2670
|
+
//# sourceMappingURL=custom-block-CsZN9qMM.js.map
|