English
Layout
Pass a JSON document with exactly one slot. Preview and export wrap the author's email in that shell. Save does not: getContent() is the authored template only.
ts
init({ layout?: TemplateContent; sectionWrapper?: boolean })
renderToMjml(content, { layout?: TemplateContent })Build the shell with createSlotBlock() and, for a card, createWrapperBlock(). createBlock('slot') and createBlock('wrapper') throw. sectionWrapper is editor chrome and independent of layout. renderToMjml does not take sectionWrapper.
Example
Grey page background, a "View in browser" line, the author's sections inside a white card, Imprint below the card. This is the wrap-around shell: preview and toMjml() / toHtml() compose it; the editing canvas and getContent() do not.
ts
import {
init,
createSlotBlock,
createWrapperBlock,
createParagraphBlock,
createDefaultTemplateContent,
} from '@templatical/editor';
const layout = createDefaultTemplateContent();
layout.settings.backgroundColor = '#f3f4f6';
layout.blocks = [
createParagraphBlock({
content:
'<p style="text-align:center"><a href="https://example.com/view">View in browser</a></p>',
}),
createWrapperBlock({
styles: {
backgroundColor: '#ffffff',
padding: { top: 24, right: 24, bottom: 24, left: 24 },
},
borderRadius: 12,
children: [createSlotBlock()],
}),
createParagraphBlock({
content:
'<p style="text-align:center"><a href="https://example.com/imprint">Imprint</a></p>',
}),
];
const editor = await init({
container: '#editor',
layout,
sectionWrapper: false,
});mj-body ← grey mat (layout.settings.backgroundColor)
mj-section ← View in browser
mj-wrapper ← white card
[author sections…]
mj-section ← ImprintsectionWrapper: false hides Add wrapper on author sections. With the slot inside a wrapper, that control would emit mj-wrapper inside mj-wrapper, which MJML forbids. Omit sectionWrapper (or pass true) if you want the panel; the editor still disables turning it on for this card layout.
The contract
layout is a TemplateContent with exactly one slot.
| Surface | With layout |
|---|---|
getContent() / setContent() / load / save / onChange / history | authored template only |
| Editing canvas | authored template only |
| Section toolbar Wrapper panel | In the editor |
renderToMjml(content) (no layout argument) | authored template only |
| Preview canvas | composed |
editor.toMjml() / toHtml() | composed |
RenderPayload.content | composed |
init runs validateLayout on the shell (after merge-tag normalization). applyLayout runs at preview and at toMjml / toHtml / renderToMjml(content, { layout }). It clones: authored block ids are left alone; layout block ids are new on the clone.
Saved JSON
getContent(), load, save, and the editing canvas never include layout blocks. Send with toMjml() / toHtml(), or call applyLayout on the server. Hiding Add wrapper does not strip section.wrapper from stored content.
slot is where authored content.blocks land. Exactly one in the layout tree. Legal as a top-level layout.blocks child or as a wrapper.children child. Illegal in a section column, in a nested wrapper, and in editor content. The palette omits it. A slot that reaches renderToMjml without layout throws.
wrapper is the layout card: styles.backgroundColor / styles.padding / borderRadius map to mj-wrapper. Layout-only. Refused in editor content (setContent / load / addBlock / createBlock('wrapper')). Authors still use section.wrapper as the per-section shorthand.
Layout factories and helpers (createSlotBlock, createWrapperBlock, createParagraphBlock, createDefaultTemplateContent, applyLayout, validateLayout, isSlot, isWrapper, layoutWrapsSlot) are exported from @templatical/editor. Headless rendering can import applyLayout from @templatical/types without the editor.
validateLayout throws:
[Templatical] layout: must contain exactly one slot block
[Templatical] layout: slot must be a top-level or wrapper child, not nested in a section
[Templatical] layout: a wrapper cannot contain a wrappersetContent / load / addBlock refuse slot and wrapper in content.
MJML forbids mj-wrapper inside mj-wrapper. If the slot sits inside a wrapper, applyLayout walks the injected content.blocks. Any block that would emit mj-wrapper — section.wrapper set, or type === 'wrapper' — throws:
[Templatical] layout: a wrapper around the slot cannot contain blocks that emit mj-wrapper (section.wrapper)A slot in children: [[slot]] is mj-section inside mj-column, which MJML also forbids. Keep the slot as a body child or a wrapper child.
Header and footer
No card: the slot is a sibling of the header and footer. Author section.wrapper is then a sibling mj-wrapper under mj-body, which is valid — leave sectionWrapper unset if authors should still get Add wrapper.
ts
layout.blocks = [
createParagraphBlock({
content:
'<p style="text-align:center"><a href="https://example.com/view">View in browser</a></p>',
}),
createSlotBlock(),
createParagraphBlock({
content:
'<p style="text-align:center"><a href="https://example.com/imprint">Imprint</a></p>',
}),
];mj-body ← grey mat
mj-section ← View in browser
[author sections…] ← may include section.wrapper
mj-section ← ImprintIn the editor
The Wrapper panel is the section toolbar's Add wrapper switch plus colour / padding / radius when it is on. Card-layout disable and sectionWrapper are independent: setting layout does not hide the panel. A header / slot / footer shell still offers Add wrapper.
When layout is set and the slot sits inside a wrapper, the switch cannot turn on. A muted line sits under it:
This editor already frames the email. An extra frame on this section isn't supported — preview and export will fail.
If a loaded template already has section.wrapper, the switch stays on and enabled so it can be turned off. The note still shows.
ts
init({ sectionWrapper?: boolean })| Value | Panel |
|---|---|
omitted / true | shown, plus the card-layout disable above |
false | hidden, including where Add wrapper is legal (header / slot / footer, or no layout) |
sectionWrapper: false does not strip section.wrapper from content, does not refuse updateBlock, and does not change getContent(). Hiding never changes a value.
If sectionWrapper === false and the selected section already has wrapper, the panel still renders and the switch stays enabled so it can be turned off. Once it is off, the panel hides. The card-layout note still shows when the panel is visible.
Settings
Layout settings is a full TemplateSettings. Only backgroundColor is read.
| Field | Winner |
|---|---|
backgroundColor | layout → mj-body |
width, fontFamily, textColor, linkColor, linkUnderline, locale, preheaderText, direction | content |
Content settings.backgroundColor is not mutated. Template Settings still edits it. It is not mj-body when a layout is applied. direction comes from the authored template. Layout does not set dir.
You can copy the content settings onto the layout and override backgroundColor.
Headless use
ts
import { renderToMjml } from '@templatical/renderer';
import { applyLayout } from '@templatical/types';
const mjml = await renderToMjml(content, { layout });
const composed = applyLayout(layout, content);Preview, toMjml, and toHtml compose first, then optionally resolvePreview:
base = applyLayout(layout, content)
if (resolvePreview) base = await resolvePreview({ content: base, recipient })The editing canvas never takes this path. Layout compose is synchronous.
When layout is set, PreviewResolveContext.content is the composed document. Return that shape. Returning only inner content drops the shell.
renderToMjml(content) with no layout argument does not call applyLayout.
Test email: payload.content is the authored template; MJML/HTML from toMjml / toHtml includes the shell.
Lint runs on editor content. The shell is not linted.
A send path that re-renders from stored JSON without layout will drop the shell. Pass the same layout to renderToMjml (or call applyLayout) on the server.