@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 @@
|
|
|
1
|
+
{"version":3,"file":"src-SZZkttBC.js","names":[],"sources":["../src/operations.ts"],"sourcesContent":["// A pure reducer over the template operation vocabulary.\n//\n// `applyOperation(content, payload) -> { ok, content, error }`. The input is\n// never mutated: every operation works on a `safeClone` and returns a new\n// document, so a rejected operation leaves the caller's copy provably untouched.\n//\n// Two deliberate differences from the editor's own mutators in\n// `@templatical/core` (`src/editor.ts`), which this mirrors:\n//\n// 1. **Failure is explicit.** Core silently `return`s on an invalid operation,\n// which is right for a UI — a drag that can't drop simply doesn't. An agent\n// gets no such feedback, so a silent no-op would read as success and the\n// next instruction would build on a state that never happened. Every\n// rejection here carries a reason.\n// 2. **No collaboration locks.** `lockedBlocks` is a Cloud concern; this runs\n// against a local working file with a single writer.\n//\n// Core is FSL and cannot be imported here, so the shared invariants — above all\n// the section-into-column refusal (issue #292) — are reimplemented. That is a\n// real drift risk: a guard added to core will not appear here. `tests/\n// operations.test.ts` states each invariant against both descriptions so the\n// pair has to be reconciled deliberately.\n//\n// Note the payload keys are camelCase (`blockId`, `targetSectionId`), matching\n// `@templatical/types` and core. Cloud's MCP tools emit snake_case in their own\n// `data` payloads, so this reducer is NOT wire-compatible with Cloud broadcasts\n// and is not meant to be.\n\nimport { safeClone } from \"@templatical/types\";\nimport type {\n Block,\n ColumnLayout,\n TemplateOperationPayload,\n SectionBlock,\n TemplateContent,\n TemplateSettings,\n} from \"@templatical/types\";\n\n/** Columns a layout declares. Mirrors the helper in core's editor. */\nexport function getColumnCount(layout: ColumnLayout): number {\n if (layout === \"1\") return 1;\n if (layout === \"3\") return 3;\n return 2;\n}\n\nfunction findBlockById(blocks: Block[], id: string): Block | null {\n for (const block of blocks) {\n if (block.id === id) return block;\n if (block.type === \"section\") {\n for (const column of block.children) {\n const found = findBlockById(column, id);\n if (found) return found;\n }\n }\n }\n return null;\n}\n\ninterface ParentRef {\n blocks: Block[];\n sectionId?: string;\n columnIndex?: number;\n}\n\nfunction findBlockParent(\n blocks: Block[],\n id: string,\n parent: ParentRef = { blocks },\n): ParentRef | null {\n for (const block of blocks) {\n if (block.id === id) return parent;\n if (block.type === \"section\") {\n for (let colIdx = 0; colIdx < block.children.length; colIdx++) {\n const result = findBlockParent(block.children[colIdx], id, {\n blocks: block.children[colIdx],\n sectionId: block.id,\n columnIndex: colIdx,\n });\n if (result) return result;\n }\n }\n }\n return null;\n}\n\nexport interface OperationResult {\n ok: boolean;\n /** The new document on success; the unchanged input on failure. */\n content: TemplateContent;\n error?: string;\n}\n\nfunction reject(content: TemplateContent, error: string): OperationResult {\n return { ok: false, content, error };\n}\n\n/**\n * Resolve the array an operation targets, enforcing the column rules.\n * Returns a string on rejection so callers can surface the reason.\n */\nfunction resolveTarget(\n content: TemplateContent,\n targetSectionId: string | undefined,\n columnIndex: number,\n): Block[] | string {\n if (!targetSectionId) return content.blocks;\n\n const section = findBlockById(content.blocks, targetSectionId);\n if (!section) return `No block with id \"${targetSectionId}\".`;\n if (section.type !== \"section\") {\n return `Block \"${targetSectionId}\" is a ${section.type}, not a section — it has no columns.`;\n }\n const count = getColumnCount((section as SectionBlock).columns);\n if (columnIndex < 0 || columnIndex >= count) {\n return `Column ${columnIndex} is out of range for a \"${(section as SectionBlock).columns}\" section (${count} column(s)).`;\n }\n const children = section as SectionBlock;\n children.children[columnIndex] = children.children[columnIndex] || [];\n return children.children[columnIndex];\n}\n\nfunction insertAt(target: Block[], block: Block, index?: number): void {\n if (index !== undefined && index < target.length) {\n target.splice(index, 0, block);\n } else {\n target.push(block);\n }\n}\n\ntype Data = Record<string, unknown>;\n\n/** Apply one operation, returning a new document. Never mutates the input. */\nexport function applyOperation(\n content: TemplateContent,\n payload: TemplateOperationPayload,\n): OperationResult {\n const data = (payload.data ?? {}) as Data;\n\n switch (payload.operation) {\n case \"setContent\": {\n const next = data.content as TemplateContent | undefined;\n if (!next || typeof next !== \"object\" || !Array.isArray(next.blocks)) {\n return reject(\n content,\n \"setContent needs `content` with a blocks array.\",\n );\n }\n return { ok: true, content: safeClone(next) };\n }\n\n case \"updateSettings\": {\n const updates = data.settings as Partial<TemplateSettings> | undefined;\n if (!updates || typeof updates !== \"object\") {\n return reject(content, \"updateSettings needs a `settings` object.\");\n }\n const draft = safeClone(content);\n draft.settings = { ...draft.settings, ...updates };\n return { ok: true, content: draft };\n }\n\n case \"addBlock\": {\n const block = data.block as Block | undefined;\n if (!block || typeof block !== \"object\" || typeof block.id !== \"string\") {\n return reject(content, \"addBlock needs a `block` with an id.\");\n }\n const targetSectionId = data.targetSectionId as string | undefined;\n\n // Sections cannot nest inside a column — MJML forbids `mj-section` inside\n // `mj-column`, so the renderer drops them on export (issue #292). Reject\n // up front rather than lose the content silently at render time.\n if (targetSectionId && block.type === \"section\") {\n return reject(\n content,\n \"A section cannot be nested inside a section column — MJML would drop it on export. Add it at the top level instead.\",\n );\n }\n\n const draft = safeClone(content);\n if (findBlockById(draft.blocks, block.id)) {\n return reject(content, `A block with id \"${block.id}\" already exists.`);\n }\n const target = resolveTarget(\n draft,\n targetSectionId,\n (data.columnIndex as number | undefined) ?? 0,\n );\n if (typeof target === \"string\") return reject(content, target);\n\n insertAt(target, safeClone(block), data.index as number | undefined);\n return { ok: true, content: draft };\n }\n\n case \"updateBlock\": {\n const blockId = data.blockId as string | undefined;\n const updates = data.updates as Partial<Block> | undefined;\n if (!blockId) return reject(content, \"updateBlock needs a `blockId`.\");\n if (!updates || typeof updates !== \"object\") {\n return reject(content, \"updateBlock needs an `updates` object.\");\n }\n const draft = safeClone(content);\n const block = findBlockById(draft.blocks, blockId);\n if (!block) return reject(content, `No block with id \"${blockId}\".`);\n if (\"type\" in updates && updates.type !== block.type) {\n // Changing type in place would leave the block carrying the previous\n // type's fields, which no longer validate against its new subschema.\n return reject(\n content,\n `Cannot change a block's type (${block.type} → ${String(updates.type)}). Delete it and add the new block instead.`,\n );\n }\n Object.assign(block, updates);\n return { ok: true, content: draft };\n }\n\n case \"updateBlockStyle\": {\n const blockId = data.blockId as string | undefined;\n const styles = data.styles as Record<string, unknown> | undefined;\n if (!blockId) {\n return reject(content, \"updateBlockStyle needs a `blockId`.\");\n }\n if (!styles || typeof styles !== \"object\") {\n return reject(content, \"updateBlockStyle needs a `styles` object.\");\n }\n const draft = safeClone(content);\n const block = findBlockById(draft.blocks, blockId);\n if (!block) return reject(content, `No block with id \"${blockId}\".`);\n // Merge rather than replace, so a caller can set one property without\n // restating padding and every other style the block already carries.\n block.styles = {\n ...block.styles,\n ...styles,\n } as Block[\"styles\"];\n return { ok: true, content: draft };\n }\n\n case \"deleteBlock\": {\n const blockId = data.blockId as string | undefined;\n if (!blockId) return reject(content, \"deleteBlock needs a `blockId`.\");\n const draft = safeClone(content);\n const parent = findBlockParent(draft.blocks, blockId);\n if (!parent) return reject(content, `No block with id \"${blockId}\".`);\n const index = parent.blocks.findIndex((b) => b.id === blockId);\n parent.blocks.splice(index, 1);\n return { ok: true, content: draft };\n }\n\n case \"moveBlock\": {\n const blockId = data.blockId as string | undefined;\n const index = data.index as number | undefined;\n if (!blockId) return reject(content, \"moveBlock needs a `blockId`.\");\n if (typeof index !== \"number\" || index < 0) {\n return reject(content, \"moveBlock needs a non-negative `index`.\");\n }\n const targetSectionId = data.targetSectionId as string | undefined;\n\n const draft = safeClone(content);\n const parent = findBlockParent(draft.blocks, blockId);\n if (!parent) return reject(content, `No block with id \"${blockId}\".`);\n const oldIndex = parent.blocks.findIndex((b) => b.id === blockId);\n\n if (targetSectionId && parent.blocks[oldIndex].type === \"section\") {\n return reject(\n content,\n \"A section cannot be moved into a section column — MJML would drop it on export.\",\n );\n }\n if (targetSectionId === blockId) {\n return reject(content, \"A block cannot be moved into itself.\");\n }\n\n // Resolve the target BEFORE splicing the source: an invalid target would\n // otherwise leave the block removed and unrecoverable.\n const target = resolveTarget(\n draft,\n targetSectionId,\n (data.columnIndex as number | undefined) ?? 0,\n );\n if (typeof target === \"string\") return reject(content, target);\n\n const [block] = parent.blocks.splice(oldIndex, 1);\n target.splice(index, 0, block);\n return { ok: true, content: draft };\n }\n\n default: {\n return reject(\n content,\n `Unknown operation \"${String(payload.operation)}\".`,\n );\n }\n }\n}\n"],"mappings":"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"}
|
package/live/index.html
CHANGED
|
@@ -1304,6 +1304,58 @@
|
|
|
1304
1304
|
};
|
|
1305
1305
|
|
|
1306
1306
|
// ---- Boot ----
|
|
1307
|
+
// ---- Custom-block mode ----
|
|
1308
|
+
// The bridge serves the definition without its preview recipe; onFetch
|
|
1309
|
+
// asks the bridge, which holds the recipe and the secrets. The page sends
|
|
1310
|
+
// fieldValues only — never a URL or headers.
|
|
1311
|
+
let customBlock = null; // { definition, dataSource: { label } | null }
|
|
1312
|
+
|
|
1313
|
+
async function loadCustomBlock() {
|
|
1314
|
+
try {
|
|
1315
|
+
const res = await fetch("/custom-block");
|
|
1316
|
+
if (res.status === 200) {
|
|
1317
|
+
customBlock = await res.json();
|
|
1318
|
+
} else {
|
|
1319
|
+
customBlock = null;
|
|
1320
|
+
if (res.status === 422) {
|
|
1321
|
+
toast((await res.json().catch(() => ({}))).error ?? "Custom block is invalid");
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
} catch {
|
|
1325
|
+
customBlock = null;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function customBlocksConfig() {
|
|
1330
|
+
if (!customBlock) return undefined;
|
|
1331
|
+
const definition = { ...customBlock.definition };
|
|
1332
|
+
if (customBlock.dataSource) {
|
|
1333
|
+
definition.dataSource = {
|
|
1334
|
+
label: customBlock.dataSource.label,
|
|
1335
|
+
onFetch: async ({ fieldValues }) => {
|
|
1336
|
+
const res = await fetch("/data-source/fetch", {
|
|
1337
|
+
method: "POST",
|
|
1338
|
+
headers: { "content-type": "application/json" },
|
|
1339
|
+
body: JSON.stringify({ fieldValues }),
|
|
1340
|
+
});
|
|
1341
|
+
const body = await res.json().catch(() => ({}));
|
|
1342
|
+
// Throwing (not returning null, which reads as "cancelled") puts
|
|
1343
|
+
// the toolbar in its fetch-error state, which stays visible.
|
|
1344
|
+
if (!res.ok) {
|
|
1345
|
+
const message = body.error ?? "Fetch failed";
|
|
1346
|
+
toast(message);
|
|
1347
|
+
throw new Error(message);
|
|
1348
|
+
}
|
|
1349
|
+
if (body.unmapped?.length) {
|
|
1350
|
+
toast("Not in the response: " + body.unmapped.join(", "));
|
|
1351
|
+
}
|
|
1352
|
+
return body.values;
|
|
1353
|
+
},
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
return [definition];
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1307
1359
|
async function mountEditor(content) {
|
|
1308
1360
|
applyingRemote = true;
|
|
1309
1361
|
try {
|
|
@@ -1316,6 +1368,7 @@
|
|
|
1316
1368
|
locale: readLocale(),
|
|
1317
1369
|
content, // undefined → editor starts from its default empty template
|
|
1318
1370
|
uiTheme: readTheme(),
|
|
1371
|
+
customBlocks: customBlocksConfig(),
|
|
1319
1372
|
onChange: () => {
|
|
1320
1373
|
if (!applyingRemote) scheduleContentPost();
|
|
1321
1374
|
},
|
|
@@ -1328,6 +1381,7 @@
|
|
|
1328
1381
|
}
|
|
1329
1382
|
|
|
1330
1383
|
async function boot() {
|
|
1384
|
+
await loadCustomBlock();
|
|
1331
1385
|
let content;
|
|
1332
1386
|
try {
|
|
1333
1387
|
const res = await fetch("/template");
|
|
@@ -1348,6 +1402,16 @@
|
|
|
1348
1402
|
/* malformed frame; ignore */
|
|
1349
1403
|
}
|
|
1350
1404
|
});
|
|
1405
|
+
es.addEventListener("custom-block", async (e) => {
|
|
1406
|
+
try {
|
|
1407
|
+
const { block, template } = JSON.parse(e.data);
|
|
1408
|
+
customBlock = block;
|
|
1409
|
+
await mountEditor(template);
|
|
1410
|
+
toast("Updated from agent");
|
|
1411
|
+
} catch {
|
|
1412
|
+
/* malformed frame; ignore */
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1351
1415
|
es.onopen = () => setStatus("live", "Live");
|
|
1352
1416
|
es.onerror = () => setStatus("reconnecting", "Reconnecting…");
|
|
1353
1417
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@templatical/template-tools",
|
|
3
3
|
"description": "CLI and library for Templatical email templates — validate, render, edit, import, and live-preview",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.48.0",
|
|
5
5
|
"bin": {
|
|
6
6
|
"templatical": "./dist/bin.js"
|
|
7
7
|
},
|
|
8
8
|
"bugs": "https://github.com/templatical/sdk/issues",
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@templatical/quality": "0.
|
|
11
|
-
"@templatical/renderer": "0.
|
|
12
|
-
"@templatical/types": "0.
|
|
13
|
-
"ajv": "^8.20.0"
|
|
10
|
+
"@templatical/quality": "0.48.0",
|
|
11
|
+
"@templatical/renderer": "0.48.0",
|
|
12
|
+
"@templatical/types": "0.48.0",
|
|
13
|
+
"ajv": "^8.20.0",
|
|
14
|
+
"htmlparser2": "^12.0.0",
|
|
15
|
+
"liquidjs": "^10.30.0"
|
|
14
16
|
},
|
|
15
17
|
"devDependencies": {
|
|
16
18
|
"@types/node": "^25.9.9",
|
|
17
19
|
"mjml": "^5.4.1",
|
|
18
20
|
"ts-json-schema-generator": "^2.9.0",
|
|
19
21
|
"typescript": "^6.0.3",
|
|
20
|
-
"vitest": "^5.0.
|
|
22
|
+
"vitest": "^5.0.4"
|
|
21
23
|
},
|
|
22
24
|
"exports": {
|
|
23
25
|
".": {
|
|
@@ -28,13 +30,15 @@
|
|
|
28
30
|
"types": "./dist/live/index.d.ts",
|
|
29
31
|
"import": "./dist/live/index.js"
|
|
30
32
|
},
|
|
31
|
-
"./schema.json": "./schema.json"
|
|
33
|
+
"./schema.json": "./schema.json",
|
|
34
|
+
"./custom-block-schema.json": "./custom-block-schema.json"
|
|
32
35
|
},
|
|
33
36
|
"files": [
|
|
34
37
|
"README.md",
|
|
35
38
|
"dist",
|
|
36
39
|
"live",
|
|
37
|
-
"schema.json"
|
|
40
|
+
"schema.json",
|
|
41
|
+
"custom-block-schema.json"
|
|
38
42
|
],
|
|
39
43
|
"homepage": "https://templatical.com",
|
|
40
44
|
"keywords": [
|
|
@@ -48,14 +52,14 @@
|
|
|
48
52
|
"license": "MIT",
|
|
49
53
|
"module": "./dist/index.js",
|
|
50
54
|
"peerDependencies": {
|
|
51
|
-
"@templatical/import-beefree": "0.
|
|
52
|
-
"@templatical/import-chamaileon": "0.
|
|
53
|
-
"@templatical/import-easy-email-pro": "0.
|
|
54
|
-
"@templatical/import-html": "0.
|
|
55
|
-
"@templatical/import-mjml": "0.
|
|
56
|
-
"@templatical/import-stripo": "0.
|
|
57
|
-
"@templatical/import-topol": "0.
|
|
58
|
-
"@templatical/import-unlayer": "0.
|
|
55
|
+
"@templatical/import-beefree": "0.48.0",
|
|
56
|
+
"@templatical/import-chamaileon": "0.48.0",
|
|
57
|
+
"@templatical/import-easy-email-pro": "0.48.0",
|
|
58
|
+
"@templatical/import-html": "0.48.0",
|
|
59
|
+
"@templatical/import-mjml": "0.48.0",
|
|
60
|
+
"@templatical/import-stripo": "0.48.0",
|
|
61
|
+
"@templatical/import-topol": "0.48.0",
|
|
62
|
+
"@templatical/import-unlayer": "0.48.0",
|
|
59
63
|
"mjml": "^5.4.1"
|
|
60
64
|
},
|
|
61
65
|
"peerDependenciesMeta": {
|
package/schema.json
CHANGED
|
@@ -140,6 +140,14 @@
|
|
|
140
140
|
"$ref": "#/definitions/BorderValue",
|
|
141
141
|
"description": "Border around the section box, per side. Omitted = no border; a side with width 0 is not drawn."
|
|
142
142
|
},
|
|
143
|
+
"backgroundImage": {
|
|
144
|
+
"$ref": "#/definitions/BackgroundImage",
|
|
145
|
+
"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."
|
|
146
|
+
},
|
|
147
|
+
"hero": {
|
|
148
|
+
"$ref": "#/definitions/SectionHero",
|
|
149
|
+
"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."
|
|
150
|
+
},
|
|
143
151
|
"wrapper": {
|
|
144
152
|
"$ref": "#/definitions/SectionWrapper",
|
|
145
153
|
"description": "Optional outer frame (rendered as an `mj-wrapper` around the section)."
|
|
@@ -310,6 +318,113 @@
|
|
|
310
318
|
"dotted"
|
|
311
319
|
]
|
|
312
320
|
},
|
|
321
|
+
"BackgroundImage": {
|
|
322
|
+
"type": "object",
|
|
323
|
+
"properties": {
|
|
324
|
+
"url": {
|
|
325
|
+
"type": "string"
|
|
326
|
+
},
|
|
327
|
+
"size": {
|
|
328
|
+
"$ref": "#/definitions/BackgroundSize"
|
|
329
|
+
},
|
|
330
|
+
"position": {
|
|
331
|
+
"type": "object",
|
|
332
|
+
"properties": {
|
|
333
|
+
"x": {
|
|
334
|
+
"$ref": "#/definitions/BackgroundPositionX"
|
|
335
|
+
},
|
|
336
|
+
"y": {
|
|
337
|
+
"$ref": "#/definitions/BackgroundPositionY"
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"required": [
|
|
341
|
+
"x",
|
|
342
|
+
"y"
|
|
343
|
+
],
|
|
344
|
+
"additionalProperties": false
|
|
345
|
+
},
|
|
346
|
+
"repeat": {
|
|
347
|
+
"type": "boolean",
|
|
348
|
+
"description": "`false` paints the image once; `true` tiles it."
|
|
349
|
+
},
|
|
350
|
+
"naturalWidth": {
|
|
351
|
+
"type": "number",
|
|
352
|
+
"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."
|
|
353
|
+
},
|
|
354
|
+
"naturalHeight": {
|
|
355
|
+
"type": "number",
|
|
356
|
+
"description": "The image file's own height in px, when known; it pairs with `naturalWidth`, and only their ratio is used. Both or neither."
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
"required": [
|
|
360
|
+
"url",
|
|
361
|
+
"size",
|
|
362
|
+
"position",
|
|
363
|
+
"repeat"
|
|
364
|
+
],
|
|
365
|
+
"additionalProperties": false,
|
|
366
|
+
"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."
|
|
367
|
+
},
|
|
368
|
+
"BackgroundSize": {
|
|
369
|
+
"type": "string",
|
|
370
|
+
"enum": [
|
|
371
|
+
"cover",
|
|
372
|
+
"contain",
|
|
373
|
+
"auto"
|
|
374
|
+
],
|
|
375
|
+
"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."
|
|
376
|
+
},
|
|
377
|
+
"BackgroundPositionX": {
|
|
378
|
+
"type": "string",
|
|
379
|
+
"enum": [
|
|
380
|
+
"left",
|
|
381
|
+
"center",
|
|
382
|
+
"right"
|
|
383
|
+
]
|
|
384
|
+
},
|
|
385
|
+
"BackgroundPositionY": {
|
|
386
|
+
"type": "string",
|
|
387
|
+
"enum": [
|
|
388
|
+
"top",
|
|
389
|
+
"center",
|
|
390
|
+
"bottom"
|
|
391
|
+
]
|
|
392
|
+
},
|
|
393
|
+
"SectionHero": {
|
|
394
|
+
"type": "object",
|
|
395
|
+
"properties": {
|
|
396
|
+
"height": {
|
|
397
|
+
"anyOf": [
|
|
398
|
+
{
|
|
399
|
+
"type": "string",
|
|
400
|
+
"const": "image"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"type": "number"
|
|
404
|
+
}
|
|
405
|
+
],
|
|
406
|
+
"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."
|
|
407
|
+
},
|
|
408
|
+
"verticalAlign": {
|
|
409
|
+
"$ref": "#/definitions/HeroVerticalAlign"
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
"required": [
|
|
413
|
+
"height",
|
|
414
|
+
"verticalAlign"
|
|
415
|
+
],
|
|
416
|
+
"additionalProperties": false,
|
|
417
|
+
"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."
|
|
418
|
+
},
|
|
419
|
+
"HeroVerticalAlign": {
|
|
420
|
+
"type": "string",
|
|
421
|
+
"enum": [
|
|
422
|
+
"top",
|
|
423
|
+
"middle",
|
|
424
|
+
"bottom"
|
|
425
|
+
],
|
|
426
|
+
"description": "Where a hero's content sits in its box."
|
|
427
|
+
},
|
|
313
428
|
"SectionWrapper": {
|
|
314
429
|
"type": "object",
|
|
315
430
|
"properties": {
|
|
@@ -322,6 +437,10 @@
|
|
|
322
437
|
"borderRadius": {
|
|
323
438
|
"$ref": "#/definitions/BorderRadiusValue",
|
|
324
439
|
"description": "Corner radius in px for the outer frame — one number, or a radius per corner. Omitted/0 = square corners."
|
|
440
|
+
},
|
|
441
|
+
"backgroundImage": {
|
|
442
|
+
"$ref": "#/definitions/BackgroundImage",
|
|
443
|
+
"description": "Image behind the band; `backgroundColor` is its fallback."
|
|
325
444
|
}
|
|
326
445
|
},
|
|
327
446
|
"additionalProperties": false,
|
|
@@ -531,6 +650,13 @@
|
|
|
531
650
|
"type": "number",
|
|
532
651
|
"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`."
|
|
533
652
|
},
|
|
653
|
+
"naturalWidth": {
|
|
654
|
+
"type": "number",
|
|
655
|
+
"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."
|
|
656
|
+
},
|
|
657
|
+
"naturalHeight": {
|
|
658
|
+
"type": "number"
|
|
659
|
+
},
|
|
534
660
|
"align": {
|
|
535
661
|
"type": "string",
|
|
536
662
|
"enum": [
|
|
@@ -1567,6 +1693,10 @@
|
|
|
1567
1693
|
"renderedHtml": {
|
|
1568
1694
|
"type": "string"
|
|
1569
1695
|
},
|
|
1696
|
+
"renderedStylesheet": {
|
|
1697
|
+
"type": "string",
|
|
1698
|
+
"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."
|
|
1699
|
+
},
|
|
1570
1700
|
"dataSourceFetched": {
|
|
1571
1701
|
"type": "boolean"
|
|
1572
1702
|
}
|
|
@@ -1681,6 +1811,10 @@
|
|
|
1681
1811
|
},
|
|
1682
1812
|
"borderRadius": {
|
|
1683
1813
|
"type": "number"
|
|
1814
|
+
},
|
|
1815
|
+
"backgroundImage": {
|
|
1816
|
+
"$ref": "#/definitions/BackgroundImage",
|
|
1817
|
+
"description": "Image behind the band; `styles.backgroundColor` is its fallback."
|
|
1684
1818
|
}
|
|
1685
1819
|
},
|
|
1686
1820
|
"required": [
|
|
@@ -1690,7 +1824,7 @@
|
|
|
1690
1824
|
"type"
|
|
1691
1825
|
],
|
|
1692
1826
|
"additionalProperties": false,
|
|
1693
|
-
"description": "Layout-only band rendered as `mj-wrapper`. The block is the band: `styles.backgroundColor` / `styles.padding` / `borderRadius` map to the wrapper. Legal in layout only
|
|
1827
|
+
"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."
|
|
1694
1828
|
},
|
|
1695
1829
|
"TemplateSettings": {
|
|
1696
1830
|
"type": "object",
|