Skip to content

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                    ← Imprint

sectionWrapper: 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.

SurfaceWith layout
getContent() / setContent() / load / save / onChange / historyauthored template only
Editing canvasauthored template only
Section toolbar Wrapper panelIn the editor
renderToMjml(content) (no layout argument)authored template only
Preview canvascomposed
editor.toMjml() / toHtml()composed
RenderPayload.contentcomposed

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 wrapper

setContent / 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-wrappersection.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.

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                    ← Imprint

In 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 })
ValuePanel
omitted / trueshown, plus the card-layout disable above
falsehidden, 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.

FieldWinner
backgroundColorlayout → mj-body
width, fontFamily, textColor, linkColor, linkUnderline, locale, preheaderText, directioncontent

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.