# Templatical

> Drag-and-drop email editor for modern apps — source-available, MIT after two years.

---

# Editor API

Source: https://docs.templatical.com/api/editor

The main entry point is the `init()` function from `@templatical/editor`.

## `init(config)`

Creates and mounts the editor into a container element. Returns a promise that resolves when the editor is ready.

```ts
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";

const editor = await init({
  container: "#editor",
  content: savedTemplate,
  onChange(content) {
    // Auto-save or update state
  },
});
```

**Returns:** [`TemplaticalEditor`](#templaticaleditor)

## `unmount()`

Destroys the editor instance and cleans up event listeners.

```ts
import { unmount } from "@templatical/editor";

unmount();
```

## TemplaticalEditorConfig

| Property             | Type                                                              | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| -------------------- | ----------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `container`          | `string \| HTMLElement`                                           | Yes      | CSS selector or DOM element to mount the editor into. In default (shadow DOM) mode, must be an element that can host a Shadow DOM — `<div>` is recommended. See [Container element requirements](#container-element-requirements) below                                                                                                                                                                                                                                                                                        |
| `shadowDom`          | `boolean`                                                         | No       | Mount inside a Shadow DOM for CSS isolation from the host page. Defaults to `true`. Set to `false` to mount in light DOM instead (e.g. for `document.querySelector` access to editor internals or Firefox <101 / Safari <16.4 support). See [Shadow DOM](/guide/shadow-dom) for trade-offs                                                                                                                                                                                                                                     |
| `content`            | `TemplateContent`                                                 | No       | Initial template content. Defaults to empty template                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `layout`             | `TemplateContent`                                                 | No       | Embedder-owned shell applied at preview and `toMjml()` / `toHtml()`. Never written into `getContent()`, save, history, or the editing canvas. A `TemplateContent` with exactly one `slot`. Throws if the slot sits in a wrapper and the template emits `mj-wrapper`. See [Layout](/guide/layout)                                                                                                                                                                                                                               |
| `sectionWrapper`     | `boolean`                                                         | No       | Whether the section toolbar offers Add wrapper. Omitted / `true` is on. `false` hides the control, including where a wrapper is legal. Already-on wrappers stay reachable. Presentation only: hiding never strips `section.wrapper`. Independent of `layout`. See [Layout](/guide/layout)                                                                                                                                                                                                                                     |
| `onChange`           | `(content: TemplateContent) => void`                              | No       | Called when template content changes (debounced)                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `onError`            | `(error: Error) => void`                                          | No       | Called when an error occurs                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `onDirtyChange`      | `(isDirty: boolean) => void`                                      | No       | Called whenever the unsaved-changes state flips. Works with or without a `templates` provider — use it to guard a client-side router, which `beforeunload` cannot cover. See [Saving & Loading](/backend/templates#unsaved-changes)                                                                                                                                                                                                                                                                                            |
| `templates`          | `TemplatesProvider`                                               | No       | Storage backend for the template itself — the save/load lifecycle. The same object also carries `autoSave`, `unsavedChangesGuard` and `nameField` config, plus the `onSaved` / `onCreated` / `onLoaded` events — none of it means anything without somewhere to save to. The editor provides the name field, save button, save status and Cmd+S; you provide persistence. Omit `templates` and the feature is off entirely: no name field, no save button, no status indicator. See [Saving & Loading](/backend/templates)     |
| `render`             | `RenderProvider`                                                  | No       | Rendering backend for `toMjml()` / `toHtml()`. Every method (`toMjml`, `toHtml`, `compileMjml`) is independently optional and resolved separately. Omit and `toMjml()` renders locally while `toHtml()` rejects — the SDK bundles no MJML compiler. See [Rendering & Export](/backend/render)                                                                                                                                                                                                                                  |
| `versionHistory`     | `VersionHistoryProvider`                                          | No       | Storage backend for the template's version history — the past states a user can browse, preview and restore. The editor provides the header control, preview banner and restore flow; you provide the storage. The same object also carries the `onCreated` and `onRestored` events. It never records a version itself: your `templates.save` decides that. Omit to disable the feature entirely. See [Version History](/backend/version-history)                                                                              |
| `comments`           | `CommentsProvider`                                                | No       | Storage backend for review comments — threads anchored to a block or the template. The same object also carries the `onCreated`, `onUpdated`, `onDeleted`, `onResolved` and `onUnresolved` events. The editor provides the sidebar, the composer, resolve/reply and the block indicators; you provide persistence. **Requires `user`**: without an identity the feature reports itself unavailable rather than writing anonymous comments. An optional `subscribe` pushes remote changes in. See [Comments](/backend/comments) |
| `user`               | `EditorUser`                                                      | No       | Who is using the editor — `{ id, name }`. Needed by any feature that attributes work to a person; today that is `comments`. Not a security boundary: it identifies the user to the editor's UI, in the user's own browser. Attribute writes server-side from the session your backend already trusts                                                                                                                                                                                                                           |
| `changeDebounce`     | `number`                                                          | No       | How long the editor waits after the last edit before `onChange` fires and, when `templates.autoSave` is on, before it saves. One timer drives both, so it paces `onChange` even with no `templates` provider configured. Defaults to `2000`. See [Saving & Loading](/backend/templates#autosave)                                                                                                                                                                                                                               |
| `onRequestMedia`     | `(context?: MediaRequestContext) => Promise<MediaResult \| null>` | No       | UI override for the media picker (Bynder, Cloudinary widget, a host modal). Wins over `media` when both are set. Return `{ url, alt? }` or `null`. See [Images](/guide/images) and [Media](/backend/media)                                                                                                                                                                                                                                                                                                                     |
| `resolveImageUrl`    | `(src: string) => string \| null \| Promise<string \| null>`      | No       | Display-only resolver for image `src` values: maps a canonical src to a preview URL for the canvas. Content and `toMjml()` output keep the canonical value. Return `null` to use the src as-is. Called once per committed src (debounced), cached per src. See [Images](/guide/images#display-only-url-resolution)                                                                                                                                                                                                             |
| `mergeTags`          | `MergeTagsConfig`                                                 | No       | Merge tag configuration. Each tag may carry an optional `sample` — an example value previews render in its place. `showRawValue: false` keeps the raw token out of the authoring UI. See [Merge Tags](/guide/merge-tags)                                                                                                                                                                                                                                                                                                       |
| `resolvePreview`     | `ResolvePreview`                                                  | No       | Resolves the template for preview surfaces — typically evaluating logic tags against real data on your backend. Display-only: never in `getContent()`, a send or an export. See [Preview Rendering](/guide/preview-rendering)                                                                                                                                                                                                                                                                                                  |
| `displayConditions`  | `DisplayConditionsConfig`                                         | No       | Display condition configuration. See [Display Conditions](/guide/display-conditions)                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `logicTags`          | `LogicTagsConfig`                                                 | No       | Logic tag configuration — template-language control flow (conditionals, loops) inserted inline in rich text and text inputs. See [Logic Tags](/guide/logic-tags)                                                                                                                                                                                                                                                                                                                                                               |
| `customBlocks`       | `CustomBlockDefinition[]`                                         | No       | Custom block type definitions. See [Custom Blocks](/guide/custom-blocks)                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `savedBlocks`        | `SavedBlocksProvider`                                             | No       | Storage backend for saved blocks — reusable block groups users save and re-insert. The editor provides the UI; you provide persistence. The same object also carries the `onCreated`, `onUpdated` and `onDeleted` events. Omit to disable the feature entirely. Use `createLocalStorageSavedBlocksProvider()` for a zero-backend option. See [Saved Blocks](/backend/saved-blocks)                                                                                                                                             |
| `media`              | `MediaProvider`                                                   | No       | Storage backend for the media library — the picker behind Browse on image fields, video thumbnails and custom-block image fields. The editor provides the modal, grid, crop and insertion; you provide persistence. `list` is required; every other method is `false \| fn`. The same object also carries `onCreated` / `onUpdated` / `onDeleted`. Omit and image fields stay URL-only unless `onRequestMedia` is set. Use `createLocalStorageMediaProvider()` for a zero-backend option. See [Media](/backend/media)          |
| `testEmail`          | `TestEmailProvider`                                               | No       | Sending backend for test emails — lets users mail themselves the template they are editing. The editor provides the trigger, dialog, validation and states; you provide delivery. The same object also carries the `onSent` event. Omit to disable the feature entirely. `allowedRecipients` restricts the picker but is **not** a security boundary — validate server-side. See [Test Emails](/backend/test-email)                                                                                                            |
| `paletteBlocks`      | `string[]`                                                        | No       | Allowlist + order for the block palette. Only the listed types appear, in this order; unlisted built-ins are hidden. Built-ins use their bare type (`'image'`), custom blocks the `custom:`-prefixed type (`'custom:qrcode'`). See [Customizing the block palette](#customizing-the-block-palette)                                                                                                                                                                                                                             |
| `htmlBlockPreview`   | `boolean \| { enabled: boolean }`                                 | No       | Render each HTML block's content as a live preview in the canvas — inside a sandboxed `<iframe>` with no script execution — instead of the static placeholder. Defaults to `false`. Preview-only; the MJML/HTML export renders HTML blocks regardless. See [Previewing HTML blocks](#previewing-html-blocks)                                                                                                                                                                                                                   |
| `blockDefaults`      | `BlockDefaults`                                                   | No       | Default property overrides for new blocks. See [Defaults](/guide/defaults)                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `templateDefaults`   | `TemplateDefaults`                                                | No       | Default template settings for empty templates. See [Defaults](/guide/defaults)                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `fonts`              | `FontsConfig`                                                     | No       | Font configuration. See [Custom Fonts](/guide/fonts)                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `socialIconsBaseUrl` | `string`                                                          | No       | Base URL for the social-icon PNGs in exported MJML, forwarded to the renderer’s [`socialIconsBaseUrl`](/api/renderer-typescript#social-icons). Set it to self-host the icons; omit to use the version-pinned jsDelivr default. Applies to the local `toMjml()` / `toHtml()` (via `compileMjml`) path only — a `render.toMjml` provider passes the URL to its own `renderToMjml`                                                                                                                                                |
| `colors`             | `ColorsConfig`                                                    | No       | Color-picker palette. `presets` render as a clickable grid in every picker; `allowCustom: false` locks authors to them. See [Preset colors](#preset-colors)                                                                                                                                                                                                                                                                                                                                                                    |
| `templateSettings`   | `TemplateSettingsConfig`                                          | No       | Which template settings the Settings panel exposes. `fields` is an allowlist over the members of `TemplateSettings`, or `false` for none (which removes the tab). Omit for every setting. See [Restricting the settings panel](#restricting-the-settings-panel)                                                                                                                                                                                                                                                                |
| `theme`              | `ThemeOverrides`                                                  | No       | Color token overrides. Supports a `dark` key for dark mode overrides. See [Theming](/guide/theming)                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `uiTheme`            | `'light' \| 'dark' \| 'auto'`                                     | No       | UI color scheme. `'auto'` follows system preference. Defaults to `'auto'`                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `locale`             | `string`                                                          | No       | Locale code (e.g. `'en'`, `'de'`, `'pt-BR'`, `'es'`, `'ca'`, `'fr'`, `'nl'`, `'ja'`). Defaults to `'en'`                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `branding`           | `boolean`                                                         | No       | Show the "Powered by Templatical" footer. Defaults to `true`. Set to `false` to hide it                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `smallScreenNotice`  | `boolean`                                                         | No       | Show a "use a larger screen" notice instead of the editor on viewports narrower than ~768px. Defaults to `true`. The drag-and-drop editor is a desktop-class tool and can't lay out usably on a phone. Set to `false` to render the editor at any width if you handle small screens yourself                                                                                                                                                                                                                                   |
| `lint`               | `LintOptions`                                                     | No       | Template linter configuration, from `@templatical/quality` (an optional peer). Unset, the linter loads on demand when the panel is opened. `disabled: true` skips the import entirely and hides the sidebar tab and inline badges. See [Quality options](/quality/options)                                                                                                                                                                                                                                                     |

### Container element requirements

The default (shadow DOM) mount calls `attachShadow()` on your container, and the HTML spec only allows shadow roots on a fixed set of elements. Use one of:

`<article>`, `<aside>`, `<blockquote>`, `<body>`, `<div>` (recommended), `<footer>`, `<h1>`–`<h6>`, `<header>`, `<main>`, `<nav>`, `<p>`, `<section>`, `<span>`, plus any custom element you've defined.

**Not allowed:** `<table>`, `<tr>`, `<td>`, `<form>`, `<input>`, `<button>`, `<select>`, list elements (`<ul>`, `<ol>`, `<li>`), `<iframe>`, replaced elements (`<img>`, `<video>`, etc.). Passing one of these throws a `DOMException` from `attachShadow()`.

If your integration must use an unsupported element (e.g. mounting into a `<form>` cell of a CMS layout), pass `shadowDom: false` — light-DOM mount accepts any element. The trade-off is the host-CSS isolation you give up.

### Customizing the block palette

By default the sidebar palette lists every built-in block type. Pass `paletteBlocks` to restrict the palette to a specific set and control their order — useful for hiding block types you don't use (`video`, `table`, …) or promoting a frequently-used [custom block](/guide/custom-blocks) above the built-ins.

```ts
const editor = await init({
  container: "#editor",
  customBlocks: [qrCodeDefinition],
  paletteBlocks: [
    "section",
    "title",
    "paragraph",
    "image",
    "custom:qrcode", // a custom block, interleaved among built-ins
    "button",
  ],
});
```

- **Strict allowlist + order.** Only the listed types are shown, in exactly this order. Any built-in not listed (here `divider`, `video`, `social`, `menu`, `table`, `spacer`, `html`) is hidden from the palette.
- **Reference built-ins by their bare type** (`"section"`, `"image"`, …) and **custom blocks by their `custom:`-prefixed type** (`"custom:qrcode"`), so the two can be interleaved freely.
- **Unknown entries are skipped.** A typo, an unregistered custom block, or `countdown` outside a Cloud plan is logged to the console with a warning and left out of the palette.
- **Filtering the palette never affects rendering.** Hiding a block type only removes it from the palette — existing content that already uses that type still renders correctly. `paletteBlocks` controls what users can _insert_, not what the editor can _display_.

Omit `paletteBlocks` (or pass an empty array) to show the full default palette.

### Previewing HTML blocks {#previewing-html-blocks}

By default, an HTML block shows a placeholder card in the canvas rather than rendering its markup — the content is only realized on export. Set `htmlBlockPreview` to render each HTML block's content live in the canvas instead:

```ts
const editor = await init({
  container: "#editor",
  htmlBlockPreview: true, // shorthand for { enabled: true }
});
```

- **Off by default.** Omit the option (or pass `false` / `{ enabled: false }`) to keep the static placeholder.
- **Rendered in a sandboxed iframe.** The content is shown verbatim inside an `<iframe sandbox="allow-same-origin">` with **no** `allow-scripts` — scripts and inline event handlers never execute, and the fragment's styles can't leak into the rest of the editor. This keeps arbitrary or collaborator-authored HTML from running in your app's origin.
- **Preview-only.** This setting controls the editor canvas, not output — `renderToMjml()` / `editor.toMjml()` render HTML blocks regardless.

### Preset colors {#preset-colors}

Every color picker in the editor — block toolbars, template settings, rich-text color, custom-block color fields — opens a popover with a color wheel and a hex input. Pass `colors` to add a row of preset colors to that popover, and optionally to remove the free-form controls. It is the baseline for every picker; a custom block's color field can narrow it for itself (see the last bullet):

```ts
const editor = await init({
  container: "#editor",
  colors: {
    presets: ["#0b5cff", "#111827", "#6b7280", "#ffffff"],
    allowCustom: false,
  },
});
```

- **`presets`** — hex strings rendered as a clickable grid. Clicking one applies it; the preset matching the current value is marked selected. Supplements the wheel and hex input. Each entry must be a `#rgb` or `#rrggbb` hex string — 4-/8-digit alpha hex and other formats are skipped with a console warning listing the offending entries.
- **`allowCustom`** — defaults to `true`. Set to `false` (together with `presets`) to hide the wheel and hex input so authors can only pick from the palette — useful when embedding the editor as a white-label / brand-kit tool. In this locked mode the palette leads with a "no colour" chip that restores the unset (inherit) state, since the hex field's clear button is hidden. Also in locked mode, the editor logs a development warning when any `blockDefaults` / `templateDefaults` colour falls outside `presets` — new blocks would otherwise start on a colour no picker can reselect, so set those defaults from the same palette. Ignored with a warning when no `presets` are configured, since that would leave the picker with no way to set a color.
- **Per-field narrowing.** A custom block's `color` field can carry its own `presets` / `allowCustom` — see [per-field color presets](/guide/custom-blocks#color). A field may swap in its own palette, or lock one field while the rest of the editor stays free-form; its `presets` replace this grid for that field rather than intersecting it, so a locked field can offer colors that appear nowhere in `presets` here. What a field can never do is unlock the editor: `allowCustom: false` here still locks every picker.

### Restricting the settings panel {#restricting-the-settings-panel}

The right sidebar's Settings tab exposes every member of `TemplateSettings`. Pass `templateSettings.fields` to narrow that to an allowlist — for settings your application owns rather than the author:

```ts
const editor = await init({
  container: "#editor",
  templateSettings: {
    fields: [
      "width",
      "backgroundColor",
      "textColor",
      "linkColor",
      "linkUnderline",
      "fontFamily",
    ],
  },
});
```

| Value                           | Result                                  |
| ------------------------------- | --------------------------------------- |
| omitted, or `fields: true`      | every setting is editable               |
| `fields: [...]`                 | only the listed settings                |
| `fields: false` or `fields: []` | none — the Settings tab does not render |

The fields are `width`, `backgroundColor`, `textColor`, `linkColor`, `linkUnderline`, `fontFamily`, `locale`, `direction` and `preheaderText`.

- **Cards follow their fields.** A card renders while at least one of its settings survives, so excluding both `locale` and `direction` removes the Language card and excluding `preheaderText` removes the Preheader card. `width` is the Layout card; the five colour and font settings are the Appearance card.
- **The list narrows, it never reorders.** Settings sit in fixed cards, so unlike [`paletteBlocks`](#customizing-the-block-palette) there is no order to express.
- **An unknown entry is skipped with a console warning.** TypeScript rejects one at compile time; a JavaScript caller's typo narrows the panel rather than silently restoring every setting.
- **Hiding a setting never changes its value.** Whatever the loaded content carries keeps rendering and keeps round-tripping through `getContent()` and the export. Set the ones you hide from the content you hand the editor — `init({ content })`, or the `templates` provider's own `load`, which is the seam for "this template's locale comes from my application". [`templateDefaults`](/guide/defaults) will not do it for a template you **load**: it applies only when no content is provided. For a _blank_ template it does, and `locale` has a shortcut there — `init({ locale })` seeds the content language when you set no `templateDefaults.locale`, so hiding the field still yields the right `<html lang>`.
- **Not a security boundary.** The allowlist lives in the user's browser, like every other client-side constraint. Validate what you persist.
- **Excluding `locale` also pins the language of new blocks.** A template's `locale` drives the recipient-facing defaults a newly inserted block starts with — a video's `alt`, a countdown's labels and expired message — so with the field gone, those follow whatever locale the loaded content carries and the author can no longer move them. That is usually the point, but it means the value you set matters for more than `<html lang>`.
- **Excluding `preheaderText` leaves one linter rule unactionable.** `a11y.missing-preheader` (severity `info`) reports a template with no preheader, and with the field gone the author cannot act on it. Either set a preheader in the content you load, or turn the rule off: `lint: { accessibility: { rules: { 'a11y.missing-preheader': 'off' } } }`. No other rule reads a template setting.

::: tip Related restrictions
Other parts of the editor's chrome have their own keys: [`paletteBlocks`](#customizing-the-block-palette) for the block palette, [`fonts.builtIns`](/guide/fonts) for the font picker, [`colors.allowCustom`](#preset-colors) for free-form colours, and `templates.nameField` for the header's name field.
:::

## TemplaticalEditor

The object returned by `init()`.

### `getContent()`

Returns the current template content as a `TemplateContent` object.

```ts
const content = editor.getContent();
// { blocks: [...], settings: { width: 600, ... } }
```

### `setContent(content)`

Replaces the editor content.

```ts
import { createDefaultTemplateContent } from "@templatical/types";

editor.setContent(createDefaultTemplateContent());
```

### `setTheme(theme)`

Switches the UI color scheme at runtime without re-initializing the editor.

```ts
editor.setTheme("dark");
editor.setTheme("light");
editor.setTheme("auto"); // follow system preference
```

**Parameter:** `theme: 'light' | 'dark' | 'auto'`

### `setMergeTags(tags)`

Replaces the configured merge tags at runtime. The canvas, the sidebar fields and the built-in picker all read the same list, so they repaint together.

```ts
editor.setMergeTags([
  { label: 'First Name', value: '{{first_name}}' },
  { label: 'Plan', value: '{{plan_name}}' },
]);
```

**Parameter:** `tags: MergeTag[]`

Use it when tags are minted or renamed while the editor is open. Mutating the array passed to `init()` is not a supported substitute — it repaints nothing already on screen. See [Merge Tags](/guide/merge-tags) for the one autocomplete limitation.

### `unmount()`

Destroys this editor instance.

### `create(input?)` / `load(id)` / `save()`

The template save/load lifecycle, over the `templates` provider.

```ts
const template = await editor.create({ name: "Welcome email" });
await editor.load(template.id);
await editor.save();
```

- **`create(input?)`** persists the current content as a new template and adopts the result. Pass `content` to replace the editor's content first.
- **`load(id)`** fetches a template and makes it the editor's content, discarding local edits.
- **`save()`** persists the loaded template's name and content as one patch.

All three return a `Promise<Template>` and are always present on the type. They reject with an explanatory error when no `templates` provider is configured, when the provider withheld the relevant method (`create: false` / `save: false`), or — for `save()` — when nothing has been created or loaded yet. See [Saving & Loading](/backend/templates).

### `isDirty()`

Whether there are edits the editor knows aren't persisted. Cleared by a successful `save()`, `create()` or `load()`.

```ts
router.beforeEach((to, from, next) => {
  if (editor.isDirty() && !confirm("Discard unsaved changes?"))
    return next(false);
  next();
});
```

`onDirtyChange` is the push-based counterpart.

### `toMjml()`

Renders the current content to MJML markup. Returns a `Promise<string>` because resolving custom blocks may require asynchronous work (the editor's liquid renderer is loaded on demand).

```ts
const mjml = await editor.toMjml();
```

Resolves `render.toMjml` first when a [`render` provider](/backend/render) supplies it, then the local `@templatical/renderer`. Rejects with a clear error when neither is available — the renderer is an optional peer dependency, so install it if you need local MJML export. See [Installation](/getting-started/installation).

### `toHtml()`

Renders the current content to sending-ready HTML.

```ts
const html = await editor.toHtml();
```

Resolves `render.toHtml` first, then `toMjml()`'s output through `render.compileMjml`. **Requires one of the two**: the SDK bundles no MJML compiler, so with no `render` provider this always rejects, and the error names the method to add. See [Rendering & Export](/backend/render).

::: tip Cloud editor
The Cloud editor exposes **both** methods, resolved against Cloud's server-side renderer (or your own `render` provider). Note Cloud renders the _saved_ template, so each call saves first.
:::

### `renderCustomBlock(block)`

Renders a single custom block to its HTML representation. Useful for headless callers that want to drive `@templatical/renderer`'s `renderCustomBlock` option from outside the editor instance — for example, when invoking the renderer directly with a custom configuration.

```ts
const html = await editor.renderCustomBlock(customBlock);
```

### `getCustomBlockStylesheet(customType)`

Returns the definition-level CSS registered for a custom block type, or `undefined` when the type is unknown or has no stylesheet. The companion to `renderCustomBlock()` for headless callers driving `@templatical/renderer`'s `getCustomBlockStylesheet` option themselves.

```ts
const css = editor.getCustomBlockStylesheet("qrcode");
```

## Core Composables

For advanced use cases, you can use the composables from `@templatical/core` directly.

### `useEditor(options)`

The core composable that manages the entire editor state: the block tree, template settings, block selection, viewport mode, and all mutation methods. This is what `init()` uses internally. Use it directly if you're building a completely custom editor UI on top of the Templatical state engine.

```ts
import { useEditor } from "@templatical/core";

const editor = useEditor({ content: templateContent });

editor.selectBlock(blockId);
editor.updateBlock(blockId, { content: "New text" });
editor.setViewport("mobile");
```

### `useHistory(options)`

Tracks content snapshots and provides undo/redo. Connects to the editor's content ref and captures state after each mutation. Configurable max history size prevents unbounded memory growth.

```ts
import { useHistory } from "@templatical/core";

const history = useHistory({
  content: editor.content,
  setContent: editor.setContent,
  isRemoteOperation: () => false, // skip recording during remote/collab updates
  maxSize: 50,
});

history.undo();
history.redo();
```

### `useBlockActions(options)`

Higher-level convenience methods for common block operations: creating a block and inserting it in one step, duplicating an existing block (deep clone with new ID), and deleting with automatic selection cleanup.

```ts
import { useBlockActions } from "@templatical/core";

const actions = useBlockActions({
  addBlock: editor.addBlock,
  removeBlock: editor.removeBlock,
  updateBlock: editor.updateBlock,
  selectBlock: editor.selectBlock,
});

const newBlock = actions.createAndAddBlock("paragraph");
actions.duplicateBlock(existingBlock);
actions.deleteBlock(blockId);
actions.updateBlockProperty(blockId, "content", "<p>Updated</p>");
```

### `useAutoSave(options)`

Watches the editor content and calls your save callback with configurable debounce. Includes pause/resume for temporarily disabling saves (e.g., during bulk operations) and a `flush()` method for immediate saves.

```ts
import { useAutoSave } from "@templatical/core";

const autoSave = useAutoSave({
  content: editor.content,
  isDirty: () => editor.state.isDirty,
  onChange: (content) => saveToServer(content),
  debounce: 2000, // the default
  enabled: true, // boolean or () => boolean
});

autoSave.flush(); // Save immediately
autoSave.cancel(); // Cancel pending debounced save
autoSave.pause(); // Pause auto-save
autoSave.resume(); // Resume
autoSave.destroy(); // Stop watching and clean up
```

### `useConditionPreview()`

Manages preview state for display conditions in the editor. Allows toggling individual blocks on/off to simulate how conditional content looks when different conditions are met.

```ts
import { useConditionPreview } from "@templatical/core";

const preview = useConditionPreview(editor);

preview.isHidden(blockId); // Check if a block is hidden in preview
preview.toggleBlock(blockId); // Toggle a block's visibility
preview.reset(); // Reset all blocks to visible
preview.hasHiddenBlocks; // ComputedRef<boolean>
```

### `useDataSourceFetch(options)`

Handles fetching external data for custom blocks with data sources. Manages loading state and error handling for the `onFetch` callback.

```ts
import { useDataSourceFetch } from "@templatical/core";

const dataFetch = useDataSourceFetch({
  definition: computed(() => customBlockDefinition),
  block: computed(() => customBlock),
  onUpdate: (fieldValues, fetched) => {
    updateBlock(block.id, { fieldValues, dataSourceFetched: fetched });
  },
});

dataFetch.isFetching; // Ref<boolean>
dataFetch.fetchError; // Ref<boolean>
dataFetch.hasDataSource; // ComputedRef<boolean>
dataFetch.needsFetch; // ComputedRef<boolean>
await dataFetch.fetch(); // Trigger the fetch
```

---

# Events

Source: https://docs.templatical.com/api/events

The editor communicates with your application through callback functions passed in the configuration.

## Content Events

### `onChange`

Called whenever the template content changes. The callback receives the full `TemplateContent` object. Changes are debounced internally.

```ts
const editor = await init({
  container: '#editor',
  onChange(content) {
    // Save to your backend
    fetch('/api/templates/123', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(content),
    });
  },
});
```

### `onDirtyChange`

Called whenever the editor gains or loses unsaved changes. Use it to drive your own save button, or to guard a client-side route change — the built-in guard covers tab close, but `beforeunload` never fires on SPA navigation.

```ts
const editor = await init({
  container: '#editor',
  onDirtyChange(isDirty) {
    setRouteGuard(isDirty);
  },
});
```

### `onError`

Called when an error occurs within the editor.

```ts
const editor = await init({
  container: '#editor',
  onError(error) {
    console.error('Editor error:', error.message);
    reportToSentry(error);
  },
});
```

## Request Events

### `onRequestMedia`

UI override for the media picker. Called when the user clicks Browse (e.g. in the image block settings) **or drags an image file onto an image block/field**. Return a `MediaResult` object, or `null` if the user cancels. When `alt` is provided, the editor automatically fills in the image's alt text.

It wins over a `media` provider when both are set — the host brought a widget, so the built-in modal never opens. The store itself is the [`media`](/backend/media) key.

On a drag-and-drop, the dropped file arrives in `context.files` — upload it and return the hosted URL. See [Drag and drop to upload](/guide/images#drag-and-drop-to-upload) for the full pattern.

```ts
import type { MediaResult } from '@templatical/types';

const editor = await init({
  container: '#editor',
  async onRequestMedia(context?): Promise<MediaResult | null> {
    const image = await openMediaPicker();
    if (!image) return null;
    return { url: image.url, alt: image.alt };
  },
});
```

If you provide neither `onRequestMedia` nor `media`, the editor shows a text input where users type or paste image URLs directly.

### `mergeTags.onRequest`

Called when the user clicks to insert a merge tag in a title or paragraph block. Return a `Promise` that resolves to a `MergeTag` object or `null` if the user cancels.

```ts
import type { MergeTag } from '@templatical/types';

const editor = await init({
  container: '#editor',
  mergeTags: {
    tags: [
      { label: 'First Name', value: '{{first_name}}' },
      { label: 'Email', value: '{{email}}' },
    ],
    async onRequest(): Promise<MergeTag | null> {
      // Show your own picker UI and return the selected tag
      const tag = await showMergeTagPicker();
      return tag; // or null if cancelled
    },
  },
});
```

If you provide `mergeTags.tags` without `onRequest`, the editor uses a built-in dropdown populated with your tags. The `onRequest` callback lets you replace that dropdown with your own UI.

## Template Events

A `templates` provider carries events beyond `load` / `create` / `save` — `onSaved`, `onCreated` and `onLoaded` — fired once the editor has settled: the template adopted, `isDirty` cleared, `isSaving`/`isLoading` false.

```ts
const editor = await init({
  container: '#editor',
  templates: {
    load, create, save,
    onSaved(template, { trigger }) {
      if (trigger === 'manual') navigate('/templates');
    },
  },
});
```

`onSaved`'s second argument names which action triggered the save, so a handler can act on a save the user asked for without also firing on every autosave tick. See [`TemplatesOptions`](/backend/templates#events) for the full reference.

## Comment Events

A `comments` provider carries events beyond `list` / `create` / `update` / `delete` / `setResolved` — `onCreated`, `onUpdated`, `onDeleted`, `onResolved` and `onUnresolved` — fired once the editor has applied the change, whether it came from a local write or arrived through `subscribe`.

```ts
const editor = await init({
  container: '#editor',
  user: { id: 'u_7', name: 'Ada Lovelace' },
  comments: {
    ...myCommentsProvider,
    onCreated(comment, { origin }) {
      if (origin === 'remote') incrementUnread();
    },
  },
});
```

Each handler's second argument carries `origin` — `'local'` for a write this editor made, `'remote'` for one that arrived through `subscribe`. See [Events](/backend/comments#events) for the full reference, including which of `onResolved` / `onUnresolved` fires.

## Saved Block Events

A `savedBlocks` provider carries events beyond `list` / `create` / `update` / `delete` — `onCreated`, `onUpdated` and `onDeleted` — fired once the editor has applied the change to its own list.

```ts
const editor = await init({
  container: '#editor',
  savedBlocks: {
    ...mySavedBlocksProvider,
    onDeleted(block) {
      logRemoval(block.id);
    },
  },
});
```

`onDeleted` receives the removed `SavedBlock` itself, not an id — `delete` resolves to nothing, so the editor passes the entry it captured before removing it. See [Events](/backend/saved-blocks#events) for the full reference.

## Version History Events

A `versionHistory` provider carries events beyond `list` / `get` / `create` / `restore` — `onCreated` and `onRestored` — fired once `create()` or `restore()` resolves.

```ts
const editor = await init({
  container: '#editor',
  versionHistory: {
    ...myVersionHistoryProvider,
    onRestored(template) {
      navigate(`/templates/${template.id}`);
    },
  },
});
```

`onRestored` takes the resulting `Template` that `restore()` resolves to, not the `TemplateVersion` that was restored from. See [Events](/backend/version-history#events) for the full reference.

## Media Events

A `media` provider carries events beyond `list` / `create` / `update` / `delete` — `onCreated`, `onUpdated` and `onDeleted` — fired once the matching mutation resolves. There is no editor-level media list: a drop-create fires `onCreated` with the stored asset and never prepends a listing row.

```ts
const editor = await init({
  container: '#editor',
  media: {
    ...myMediaProvider,
    onDeleted(asset) {
      logRemoval(asset.id);
    },
  },
});
```

`onDeleted` receives the removed `MediaAsset` itself, not an id — `delete` resolves to nothing, so the handler gets the entry captured from the modal's loaded listing before removing it. See [Events](/backend/media#events) for the full reference.

## Test Email Events

A `testEmail` provider carries one event beyond `send` — `onSent` — fired once a send resolves, with the same payload `send` was given.

```ts
const editor = await init({
  container: '#editor',
  testEmail: {
    ...myTestEmailProvider,
    onSent(payload) {
      trackEvent('test_email_sent', { recipient: payload.recipient });
    },
  },
});
```

Not called for a rejected send — that surfaces through the dialog's own inline error instead. See [Events](/backend/test-email#events) for the full reference.

## Patterns

### Debounced Auto-Save

```ts
let saveTimeout: ReturnType<typeof setTimeout>;

const editor = await init({
  container: '#editor',
  onChange(content) {
    clearTimeout(saveTimeout);
    saveTimeout = setTimeout(() => {
      saveToBackend(content);
    }, 2000);
  },
});
```

### Dirty State Tracking

```ts
const editor = await init({
  container: '#editor',
  onDirtyChange(isDirty) {
    updateSaveButton(isDirty);
  },
});
```

The editor already warns on tab close when a `templates` provider is configured — opt out with `templates: { unsavedChangesGuard: false }`. `onDirtyChange` is what you need for a client-side router, which `beforeunload` cannot see.

---

# Renderer

Source: https://docs.templatical.com/api/renderer-typescript

`@templatical/renderer` converts Templatical template JSON to MJML. Works in both browser and Node.js environments.

The renderer produces MJML only. To compile MJML to HTML for email sending, use any MJML library ([mjml](https://www.npmjs.com/package/mjml) for Node.js, [spatie/mjml-php](https://github.com/spatie/mjml-php) for PHP, etc.).

::: code-group

```bash [npm]
npm install @templatical/renderer
```

```bash [pnpm]
pnpm add @templatical/renderer
```

```bash [yarn]
yarn add @templatical/renderer
```

```bash [bun]
bun add @templatical/renderer
```

:::

## `renderToMjml(content, options?)`

Renders a `TemplateContent` object to an MJML string. Returns a `Promise<string>` — async so custom blocks (which may require async work to resolve) can be rendered in line.

```ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(templateContent);
```

**Parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `content` | `TemplateContent` | The template to render |
| `options` | `RenderOptions` | Optional rendering configuration |

**Returns:** `Promise<string>` -- MJML markup

## RenderOptions

```ts
interface RenderOptions {
  customFonts?: CustomFont[];
  defaultFallbackFont?: string;
  allowHtmlBlocks?: boolean;      // default: true
  renderCustomBlock?: (block: CustomBlock) => Promise<string>;
  blockRenderers?: BlockRendererMap;
  getCustomBlockStylesheet?: (customType: string) => string | undefined | null;
  socialIconsBaseUrl?: string;
}
```

| Option | Default | Description |
|--------|---------|-------------|
| `customFonts` | `[]` | Custom font definitions for `<mj-font>` declarations in rendered output |
| `defaultFallbackFont` | `'Arial, sans-serif'` | Fallback font stack |
| `allowHtmlBlocks` | `true` | Set to `false` to strip HTML blocks from output |
| `renderCustomBlock` | -- | Resolves custom blocks to HTML. Called once per custom block. Editor consumers pass `editor.renderCustomBlock`; headless consumers wire their own resolver. If omitted, custom blocks fall back to the block's `renderedHtml` field (if present) and otherwise are omitted. |
| `blockRenderers` | `{}` | Per-type renderer overrides keyed by `block.type`. A countdown GIF backend uses this. An override owns the hidden-on-all-viewports check. |
| `getCustomBlockStylesheet` | -- | `(customType) => string \| undefined \| null`. Called once per unique `customType`. Editor consumers pass the registry stylesheet. |
| `socialIconsBaseUrl` | version-pinned jsDelivr URL | Base URL (no trailing slash) for the social icon PNG assets. Resolved per icon to `${baseUrl}/${style}/${platform}.png`. See [Social icons](#social-icons) below. |

### Custom blocks

When the content tree contains custom blocks, the renderer asks the supplied `renderCustomBlock` callback to convert each one to HTML. From the editor:

```ts
const mjml = await renderToMjml(editor.getContent(), {
  renderCustomBlock: editor.renderCustomBlock,
});
```

Headless / Node.js consumers (no editor mounted) can provide their own resolver — for example, running the same Liquid template against the block's `fieldValues`:

```ts
import { Liquid } from 'liquidjs';

const engine = new Liquid();
const definitionsByType = new Map(/* your CustomBlockDefinition list, keyed by type */);

const mjml = await renderToMjml(content, {
  async renderCustomBlock(block) {
    const definition = definitionsByType.get(block.customType);
    if (!definition) return '';
    return engine.parseAndRender(definition.template, block.fieldValues);
  },
});
```

### Social icons

Social icon blocks are emitted as `<img src="…/{style}/{platform}.png">`. The default `socialIconsBaseUrl` points at the version-pinned jsDelivr mirror of `@templatical/renderer`, which ships pre-rasterized PNGs (every `SocialPlatform` × 5 styles) alongside the package:

```
https://cdn.jsdelivr.net/npm/@templatical/renderer@<version>/assets/social/{style}/{platform}.png
```

**Why PNGs.** Outlook desktop (Word rendering engine) does not support SVG and rejects base64 data URIs in `<img src>`. Hosted PNGs are the only format that renders across every mainstream email client.

**Why version-pinned.** Email is archival — recipients open messages months or years after they're sent. The version pin freezes the icon visuals at render time so a future redesign or regression in the package doesn't retroactively break already-delivered emails. It also avoids a per-image 302 redirect and unlocks long-lived immutable cache headers.

**Self-hosting.** Override `socialIconsBaseUrl` to serve the assets from your own CDN — useful for air-gapped environments, brand-specific theming, or removing the jsDelivr dependency:

```ts
const mjml = await renderToMjml(content, {
  socialIconsBaseUrl: 'https://cdn.example.com/email-assets/social',
});
```

The exact filenames the renderer expects are `{style}/{platform}.png` where `style` is one of `solid | outlined | rounded | square | circle` and `platform` is one of `facebook | twitter | instagram | linkedin | youtube | tiktok | pinterest | email | whatsapp | telegram | discord | snapchat | reddit | github | dribbble | behance | website`. The shipped 192×192 PNGs are a reasonable starting point if you want to mirror them.

The package also exports `DEFAULT_SOCIAL_ICONS_BASE_URL` if you want to compose URLs against the same default:

```ts
import { DEFAULT_SOCIAL_ICONS_BASE_URL } from '@templatical/renderer';
```

## Utilities

The renderer also exports utility functions:

```ts
import {
  escapeHtml,
  escapeAttr,
  convertMergeTagsToValues,
  isHiddenOnAll,
  toPaddingString,
  renderBlock,
  getCssClassAttr,
  getCssClasses,
  getWidthPercentages,
  getWidthPixels,
  DEFAULT_SOCIAL_ICONS_BASE_URL,
  RenderContext,
} from '@templatical/renderer';
```

### `escapeHtml(text)`

Escapes HTML entities (`<`, `>`, `&`, `"`, `'`) in a string. Use when inserting user content into HTML:

```ts
escapeHtml('<script>alert("xss")</script>');
// '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
```

### `escapeAttr(text)`

Escapes a string for safe use in HTML attribute values:

```ts
const alt = escapeAttr('Photo of "sunrise" at O\'Hare');
// 'Photo of &quot;sunrise&quot; at O&#x27;Hare'
```

### `convertMergeTagsToValues(html)`

Converts merge tag HTML spans (used internally by the editor's rich text system) back into their plain text syntax. The editor stores merge tags as `<span data-merge-tag="...">` elements; this function strips the spans and leaves the raw merge tag syntax:

```ts
import { convertMergeTagsToValues } from '@templatical/renderer';

// Input: editor's internal HTML format
const editorHtml = '<span data-merge-tag="{{ first_name }}">First Name</span>';

const cleaned = convertMergeTagsToValues(editorHtml);
// Output: '{{ first_name }}'
```

::: tip
You typically don't need to call this directly -- the renderer calls it internally when processing title and paragraph blocks. It's exported for advanced use cases where you're working with editor HTML outside the normal rendering pipeline.
:::

### `isHiddenOnAll(block)`

Returns `true` if a block's `visibility` has all viewports set to `false`. Useful for skipping blocks that shouldn't render at all:

```ts
if (isHiddenOnAll(block)) {
  // Skip this block entirely
}
```

### `toPaddingString(padding)`

Converts a `SpacingValue` to a CSS padding string:

```ts
toPaddingString({ top: 10, right: 20, bottom: 10, left: 20 });
// '10px 20px 10px 20px'
```

### `renderBlock(block, context)`

Renders a single block to its MJML representation. Used internally by `renderToMjml()` but exported for advanced use cases where you need to render individual blocks.

### `RenderContext`

The context object passed to block renderers. Contains render options and font configuration.

### `getCssClassAttr(block)` / `getCssClasses(block)`

Generate CSS class attributes from a block's visibility settings. Used internally for responsive hiding.

### `getWidthPercentages(layout)` / `getWidthPixels(layout, containerWidth)`

Calculate column widths for a given `ColumnLayout`. Returns an array of percentage or pixel values per column.

### `DEFAULT_SOCIAL_ICONS_BASE_URL`

The default value of `RenderOptions.socialIconsBaseUrl` — the version-pinned jsDelivr URL pointing at this package's bundled social icon PNGs. See [Social icons](#social-icons).

## Compiling MJML to HTML

After rendering to MJML, compile to HTML using any MJML library:

```ts
import { renderToMjml } from '@templatical/renderer';
import mjml2html from 'mjml';

const mjml = await renderToMjml(templateContent);
const { html } = mjml2html(mjml);

// html is ready to send via your email service
```

See [How Rendering Works](/getting-started/how-rendering-works) for more on the rendering pipeline.

---

# Template Tools

Source: https://docs.templatical.com/api/template-tools

`@templatical/template-tools` is the CLI and library behind every mechanical operation on a Templatical template: validate its JSON, render it to MJML or HTML, apply a scoped edit, import it from another editor's export format, or preview it live in the real Templatical editor. MIT-licensed, published on npm, and runs via `npx` with nothing installed into your project and no Templatical account.

The bundled [Agent Skill](/guide/agent-skill) drives this CLI internally — every command an AI coding agent runs while building or editing a template with the skill is one of the seven documented below. The CLI itself needs no agent: everything here works the same from a shell, a script, or a CI job. Install the skill with `npx skills add templatical/sdk`.

## Invoking it

Every command follows the same shape:

```bash
npx -y @templatical/template-tools <command> [options]
```

Add `--json` to any command for a single parseable JSON document on stdout. Installed as a project dependency instead, the binary is named `templatical`.

**Pin an exact version in CI.** The CLI and the block model it validates against (`@templatical/types`) are released together, so an unpinned call always matches itself — but it can grow stricter between two runs with no change of yours to explain it. Pinning makes a version bump a reviewable diff instead.

## Commands

| Command | Does |
|---|---|
| `validate <file>` | Structural check, then the quality lint |
| `schema [--out <file>]` | Print the block JSON Schema |
| `render <file> [--format mjml\|html] [-o <file>]` | Render to MJML, or to sending-ready HTML |
| `edit <file> --op '<json>'` \| `--ops <file>` | Apply one operation, or a batch, and write the result |
| `import <file> [--format <fmt>]` \| `--list-formats` | Convert a design from another tool into Templatical JSON |
| `live` \| `live reload` \| `live stop` | Preview and hand-edit the template in the real editor |
| `list` | List the working templates in `.templatical/` |

`--json` (see below) is a global flag — it works identically on every command above, not just some of them. Every command that touches a file also accepts `--cwd <dir>`, resolving relative paths against it instead of the process's own working directory.

### `validate <file>`

```bash
npx -y @templatical/template-tools validate emails/welcome.json
```

Structural validation against the generated JSON Schema — discriminator-aware, so a bad button reports `blocks[2] (button) must have required property 'url'` rather than a raw `anyOf` failure. Once the structure passes, `@templatical/quality`'s full lint (accessibility, structure, links — 31 rules) runs on top. No command-specific flags.

### `schema [--out <file> | -o <file>]`

```bash
npx -y @templatical/template-tools schema --out block-schema.json
```

Prints the same JSON Schema `validate` checks against — the block model as a plain JSON Schema document, useful for grounding an LLM prompt or generating types in another language. Without `--out`/`-o` it prints to stdout; with it, writes to that file instead (creating parent directories as needed) and prints nothing to stdout.

### `render <file> [--format mjml|html] [--out <file> | -o <file>]`

```bash
npx -y @templatical/template-tools render emails/welcome.json --format html -o welcome.html
```

Defaults to `--format mjml`. `--format html` compiles that MJML further and needs the optional `mjml` package — see "Optional dependencies" below. The template is validated structurally before rendering starts, so an invalid template fails with the same error list `validate` would give you, not a renderer crash. Without `--out`/`-o`, the result prints to stdout.

### `edit <file> --op '<json>'` or `--ops <file>`

```bash
npx -y @templatical/template-tools edit emails/welcome.json \
  --op '{"operation":"updateBlock","data":{"blockId":"button_1","updates":{"backgroundColor":"#1d4ed8"}}}'
```

One operation via `--op '<json>'`, or a batch via `--ops <file>` pointing at a JSON array of the same shape — never both. Each operation is `{ "operation": "<name>", "data": {...} }`, where `operation` is one of the seven the editor itself uses: `addBlock`, `updateBlock`, `deleteBlock`, `moveBlock`, `updateSettings`, `setContent`, `updateBlockStyle`.

A batch is all-or-nothing: the first operation that would be rejected stops the whole batch, nothing is written, and the error names which operation (by index) and why. Even once every operation in a batch succeeds, the result is re-validated structurally before anything is written — a sequence that produces an invalid document is refused and the file is left untouched. On success, `edit` overwrites `<file>` in place; there's no `--out`.

### `import <file> [--format <fmt>] [--out <name>]` or `import --list-formats`

```bash
npx -y @templatical/template-tools import design.json --format unlayer
```

Converts a design from another tool's export format into a new Templatical template, written to `.templatical/<name>.json` (`<name>` defaults to `<file>`'s own base name; override it with `--out <name>`). Without `--format`, `import` sniffs the format from the file's extension and content — reliable for most exports, but pass `--format` explicitly if detection guesses wrong. The result includes a conversion report: how many blocks converted cleanly, how many were approximated, how many fell back to a raw HTML block, and how many were skipped.

Run `import --list-formats` (optionally with `--json`) to see which converter packages are actually resolvable from your current working directory right now — see "Optional dependencies" below.

### `live` · `live reload` · `live stop`

Starts a local server that opens the real Templatical editor (loaded from the CDN) in your browser and keeps one working template file in `.templatical/` synced to it over Server-Sent Events. `live reload` pushes your latest edit to the open page; `live stop` shuts the server down. `[--file <f>]` picks the working file, `[--port <n>]` picks the port (default `4747`, falling back to a random free one if it's busy), `[--cwd <d>]` resolves both against a directory other than the current one, and `--no-open` skips launching a browser automatically. This is the protocol the [Agent Skill](/guide/agent-skill)'s live mode is built on — see that page for how an agent drives it turn by turn; the CLI command here only starts and stops the server.

### `list`

```bash
npx -y @templatical/template-tools list
```

Lists every working template under `.templatical/` (or `--cwd`'s `.templatical/`), with a title hint pulled from the first title block found in each. Useful for recovering the name of the file you were just working on.

## Exit codes

| Code | Meaning |
|---|---|
| `0` | Success. `validate` may still report `warning`/`info` lint findings — see below. |
| `1` | The template is invalid: a structural error, or a quality-lint finding of severity `error`. |
| `2` | Usage error — bad flags, a missing argument, an unreadable or unparseable input file. |
| `3` | An optional dependency isn't installed. The error message names the exact install command. |

::: tip Exit code 3 is not a failure
It means the CLI did its job and hit an optional piece it can't do without — the `mjml` package for `render --format html`, or a converter package for `import`. Handle it differently from `1`/`2` in any script or CI step you write: `3` means "install one thing and re-run," not "something is wrong with this template."
:::

## Reading `--json`

Every command's `--json` output is exactly one parseable document on stdout — nothing else is ever written there in that mode. Diagnostics, progress notes and human-readable errors all go to stderr instead, in both modes, so `<command> --json | jq .` is always safe to pipe.

`validate --json` emits `{ valid, errors, issues }`. `errors` is the structural error list, populated only when `valid` is `false`. `issues` is `@templatical/quality`'s complete report against the template — every accessibility, structure and link finding, at whatever severity that rule defaults to (`error`, `warning`, or `info`) — regardless of whether that finding affected the exit code.

**The thing this gets people:** `issues.length > 0` is not "the template failed." Most rules default to `warning` or `info` and are advisory only. A handful — missing image `alt` text, a `javascript:` URL, a duplicate block ID, low-contrast text, and a few more — default to `error`, and those *do* fail the command: `validate` already computed this for you, which is exactly what the exit code is for (`1` for a structural failure or any `severity: "error"` issue, `0` otherwise). A CI script that fails the build because the `issues` array is non-empty will reject templates the command itself considers passing. Trust the exit code — or the top-level `valid` field, if you want it in-process — over hand-rolling an "any issue fails" check against `issues`.

## Using it in CI

A minimal pull-request check — no install step for the CLI itself, because there isn't one:

```yaml
name: Validate email templates

on:
  pull_request:
    paths:
      - "emails/**/*.json"

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Validate templates
        run: |
          for file in emails/*.json; do
            npx -y @templatical/template-tools validate "$file"
          done
```

Adjust the glob to wherever your templates actually live. GitHub Actions runs a `run:` step's script with `bash -e` by default, so the loop stops and the step fails at the first template that doesn't validate — nothing here tracks an exit status by hand. Pin the `npx` call to an exact version here, as described under "Invoking it" above, so a template-tools release can't change what "passing" means for this workflow between two runs with no diff to review.

## Optional dependencies

Running the CLI never installs anything into your project on its own — `npx` resolves the package itself (from npm's cache, or by fetching it once, the first time), and that's the only network or filesystem activity a bare command performs. Two commands can each ask for exactly one more package, and the error names the install command when it's missing:

- **`render --format html`** needs `mjml`. The renderer produces MJML only — by design, this SDK bundles no MJML-to-HTML compiler of its own — so turning that into sendable HTML needs an MJML implementation, and `mjml` is the one this command knows how to load.
- **`import`** needs the converter package for whatever format you're importing.

For `import`, don't guess which converters are installed — ask the CLI:

```bash
npx -y @templatical/template-tools import --list-formats --json
```

This reports exactly what's resolvable from your current working directory right now. The converter list grows over time, so this is the only answer that can't go stale the way a hardcoded list on a doc page would.

Install either kind of optional dependency in whatever project the CLI is invoked from — both are resolved starting from your current working directory (or `--cwd`, if you passed it).

## Library use

Three entry points, for three different jobs.

### `@templatical/template-tools` (root)

```ts
import {
  schema,
  validateTemplate,
  runQualityLint,
  applyOperation,
  getColumnCount,
  type ValidationResult,
  type QualityLintResult,
  type OperationResult,
} from "@templatical/template-tools";
```

- **`schema`** — the generated JSON Schema for `TemplateContent`, as a plain object. Same content the `schema` command prints and the published `schema.json` file (below) contains.
- **`validateTemplate(data: unknown): ValidationResult`** — the same discriminator-aware structural check `validate` runs. Synchronous; needs only `ajv` and the committed schema, no build step and no other workspace package.
- **`runQualityLint(data: unknown): QualityLintResult`** — `@templatical/quality`'s `lintTemplate` layered on top, assuming structurally-valid input. Wrapped in a try/catch, so a malformed template can't crash the caller: `{ issues, error? }`, where `error` is only set if the linter itself threw.
- **`applyOperation(content: TemplateContent, payload: TemplateOperationPayload): OperationResult`** — the pure reducer behind `edit --op`. Never mutates `content`; returns `{ ok, content, error? }`, where `content` on a rejection is the same object passed in, provably untouched.
- **`getColumnCount(layout: ColumnLayout): number`** — columns a section layout declares: `'1'` → `1`, `'3'` → `3`, everything else (`'2'`, `'2-1'`, `'1-2'`) → `2`. Mirrors the identical helper in `@templatical/core`'s own editor, reimplemented here because this package never imports core — see "How it relates to the other packages" below.

### `@templatical/template-tools/live`

```ts
import { startBridge } from "@templatical/template-tools/live";
```

Node-only (`node:http`, `node:fs`) — a separate subpath so a bundler building for the browser never has to resolve those built-ins just because it imported the root entry. `startBridge(options?)` starts the same local bridge server the `live` command wraps: it serves the CDN editor harness and keeps one working template file synced to it. The returned handle exposes `reload()` and `getEditorState()` directly, so a caller already running in the same Node process can drive the bridge and read back its divergence state without an HTTP round trip to its own server. Most consumers won't need this directly — it's what `live` is built from.

### `@templatical/template-tools/schema.json`

The raw generated JSON Schema as a `.json` file — importable anywhere your bundler or runtime can load JSON (`import schema from "@templatical/template-tools/schema.json"`, or read it with `fetch`/`fs.readFileSync` outside a bundler). Identical content to the `schema` export above and to what the `schema` command prints.

**Building a "generate with AI" feature into your own product?** This is what you need. The bundled [Agent Skill](/guide/agent-skill) runs exactly this loop for a coding agent: read the block schema as grounding, generate a template, validate what comes back, and feed any errors to the model until it passes. `schema.json` and `validateTemplate` are the two pieces of that loop this package gives you directly — hand the schema to your model (a system prompt, a tool definition, structured-output mode, whatever your provider supports), then run its output through `validateTemplate` before you trust it. Nothing else in this package is relevant to that path: `runQualityLint`, `applyOperation` and `./live` all assume you already have a structurally valid template to work with.

## Related packages

Builds on three MIT packages, all real `dependencies` rather than peers, so none of them are optional: `@templatical/types` for the block model the schema is generated from, `@templatical/quality` for the lint `validate` layers on top, and `@templatical/renderer` for the MJML output `render` produces. It is not the editor — there's no visual surface in this package, and nothing in it mounts UI or touches a DOM.

It never imports `@templatical/core`, the one SDK package still under [FSL-1.1-MIT](/license-faq) rather than plain MIT. `edit`'s operation reducer (`applyOperation`) reimplements the same invariants core's own editor mutators enforce — most importantly, that a section can never land inside a column, because MJML forbids nesting `mj-section` inside `mj-column` — instead of importing them. That is a real maintenance cost: the two implementations can drift apart, which is exactly why this package's own test suite checks both descriptions of each invariant against one another. What it buys back is what keeps this package's own license plain MIT — safe to run via a bare, unauthenticated `npx`, with no license terms to reason about beyond MIT's.

---

# Types Reference

Source: https://docs.templatical.com/api/types

`@templatical/types` provides all shared TypeScript types, block factory functions, and utilities.

::: code-group

```bash [npm]
npm install @templatical/types
```

```bash [pnpm]
pnpm add @templatical/types
```

```bash [yarn]
yarn add @templatical/types
```

```bash [bun]
bun add @templatical/types
```

:::

## Template Structure

### TemplateContent

The root type representing an email template.

```ts
interface TemplateContent {
  blocks: Block[];
  settings: TemplateSettings;
}
```

### TemplateSettings

```ts
interface TemplateSettings {
  width: number;                  // Template width in pixels (default: 600)
  backgroundColor: string;        // Background color
  textColor: string;              // Default text color; text blocks inherit it (default #1a1a1a)
  linkColor?: string;             // Link color; links inherit the text color when unset
  linkUnderline: boolean;         // Underline body links (default true)
  fontFamily: string;             // Default font family
  preheaderText?: string;         // Email preheader text
  locale: string;                 // BCP-47 content language; <html lang> (default 'en')
  direction?: ContentDirection;   // 'ltr' | 'rtl'; unset follows the content language
}

type ContentDirection = 'ltr' | 'rtl';
```

### Block

A discriminated union of all block types:

```ts
type Block =
  | TitleBlock
  | ParagraphBlock
  | ImageBlock
  | ButtonBlock
  | SectionBlock
  | DividerBlock
  | VideoBlock
  | SpacerBlock
  | SocialIconsBlock
  | MenuBlock
  | TableBlock
  | HtmlBlock
  | CountdownBlock
  | CustomBlock
  | SlotBlock
  | WrapperBlock;
```

### BlockType

```ts
type BlockType =
  | 'title' | 'paragraph' | 'image' | 'button' | 'section'
  | 'divider' | 'video' | 'spacer' | 'social'
  | 'menu' | 'table' | 'html' | 'countdown' | 'custom'
  | 'slot' | 'wrapper';
```

## Base Types

### BaseBlock

All blocks extend this interface.

```ts
interface BaseBlock {
  id: string;
  type: string;
  styles: BlockStyles;
  visibility?: BlockVisibility;
  displayCondition?: DisplayCondition;
}
```

### BlockStyles

```ts
interface BlockStyles {
  padding: SpacingValue;
  backgroundColor?: string;
}
```

### SpacingValue

```ts
interface SpacingValue {
  top: number;
  right: number;
  bottom: number;
  left: number;
}
```

### BorderValue

A border described per side, like `SpacingValue`: each side has its own width, style and color, and a width of `0` leaves that side undrawn. Sections, images and buttons accept one.

```ts
interface BorderSideValue {
  width: number;   // px; 0 = side not drawn
  style: 'solid' | 'dashed' | 'dotted';
  color: string;
}

interface BorderValue {
  top: BorderSideValue;
  right: BorderSideValue;
  bottom: BorderSideValue;
  left: BorderSideValue;
}
```

### BorderRadiusValue

A corner radius in px: one number for all corners, or one per corner. Sections, section wrappers, images and buttons accept it.

```ts
type BorderRadiusValue =
  | number
  | { topLeft: number; topRight: number; bottomRight: number; bottomLeft: number };
```

### BlockVisibility

Controls on which viewports a block is visible.

```ts
interface BlockVisibility {
  desktop: boolean;
  mobile: boolean;
}
```

## Block Types

See [Blocks Guide](/guide/blocks) for detailed descriptions of each block type.

### TitleBlock

```ts
interface TitleBlock extends BaseBlock {
  type: 'title';
  content: string;          // HTML content
  level: 1 | 2 | 3 | 4;   // H1=36px, H2=28px, H3=22px, H4=18px
  color: string;
  textAlign: 'left' | 'center' | 'right';
  fontFamily?: string;
}
```

### ParagraphBlock

```ts
interface ParagraphBlock extends BaseBlock {
  type: 'paragraph';
  content: string;          // HTML content (all formatting is inline via TipTap)
}
```

### ImageBlock

```ts
interface ImageBlock extends BaseBlock {
  type: 'image';
  src: string;
  alt: string;
  width: number | 'full';
  /** Absent derives the height from the width, keeping the aspect ratio. */
  height?: number;
  align: 'left' | 'center' | 'right';
  /** Corner radius in px. Omitted/0 = square corners. */
  borderRadius?: BorderRadiusValue;
  /** Omitted = no border. */
  border?: BorderValue;
  linkUrl?: string;
  linkOpenInNewTab?: boolean;
  placeholderUrl?: string;
  decorative?: boolean;
}
```

### ButtonBlock

```ts
interface ButtonBlock extends BaseBlock {
  type: 'button';
  text: string;
  url: string;
  backgroundColor: string;
  textColor: string;
  borderRadius: BorderRadiusValue;
  /** Outline button: backgroundColor 'transparent' (the keyword), plus a textColor. */
  border?: BorderValue;
  fontSize: number;
  buttonPadding: SpacingValue;
  fontFamily?: string;
  openInNewTab?: boolean;
  width?: number | 'full';
  align: 'left' | 'center' | 'right';
}
```

### SectionBlock

Container for multi-column layouts.

```ts
interface SectionBlock extends BaseBlock {
  type: 'section';
  columns: ColumnLayout;
  border?: BorderValue;     // absent: no border
  children: Block[][];      // Array of columns, each containing blocks
  stackOnMobile?: boolean;  // absent/true: columns stack on mobile (MJML default).
                            // false: rendered as <mj-group> so they stay side-by-side.
}

type ColumnLayout = '1' | '2' | '3' | '2-1' | '1-2';
```

### DividerBlock

```ts
interface DividerBlock extends BaseBlock {
  type: 'divider';
  lineStyle: 'solid' | 'dashed' | 'dotted';
  color: string;
  thickness: number;
  width: number | 'full';
}
```

### VideoBlock

Renders as a linked thumbnail image -- email clients do not support embedded playback.

```ts
interface VideoBlock extends BaseBlock {
  type: 'video';
  url: string;
  openInNewTab?: boolean;
  thumbnailUrl: string;   // Auto-derived from a YouTube/Vimeo url when empty
  alt: string;
  width: number | 'full';
  /** Absent derives the height from the width, keeping the aspect ratio. */
  height?: number;
  align: 'left' | 'center' | 'right';
  placeholderUrl?: string;
}
```

### SpacerBlock

```ts
interface SpacerBlock extends BaseBlock {
  type: 'spacer';
  height: number;
}
```

### SocialIconsBlock

```ts
interface SocialIconsBlock extends BaseBlock {
  type: 'social';
  icons: SocialIcon[];
  iconStyle: SocialIconStyle;
  iconSize: SocialIconSize;
  spacing: number;
  align: 'left' | 'center' | 'right';
}

interface SocialIcon {
  id: string;
  platform: SocialPlatform;
  url: string;
}

type SocialPlatform =
  | 'facebook' | 'twitter' | 'instagram' | 'linkedin'
  | 'youtube' | 'tiktok' | 'pinterest' | 'email'
  | 'whatsapp' | 'telegram' | 'discord' | 'snapchat'
  | 'reddit' | 'github' | 'dribbble' | 'behance'
  | 'website';


type SocialIconStyle = 'solid' | 'outlined' | 'rounded' | 'square' | 'circle';
type SocialIconSize = 'small' | 'medium' | 'large';
```

### MenuBlock

```ts
interface MenuBlock extends BaseBlock {
  type: 'menu';
  items: MenuItemData[];
  fontSize: number;
  fontFamily?: string;
  color: string;
  linkColor?: string;
  textAlign: 'left' | 'center' | 'right';
  separator: string;
  separatorColor: string;
  spacing: number;
}

interface MenuItemData {
  id: string;
  text: string;
  url: string;
  openInNewTab: boolean;
  bold: boolean;
  underline: boolean;
  color?: string;
}
```

### TableBlock

```ts
interface TableBlock extends BaseBlock {
  type: 'table';
  rows: TableRowData[];
  hasHeaderRow: boolean;
  headerBackgroundColor?: string;
  borderColor: string;
  borderWidth: number;
  cellPadding: number;
  fontSize: number;
  fontFamily?: string;
  color: string;
  textAlign: 'left' | 'center' | 'right';
}

interface TableRowData {
  id: string;
  cells: TableCellData[];
}

interface TableCellData {
  id: string;
  content: string;
}
```

### HtmlBlock

```ts
interface HtmlBlock extends BaseBlock {
  type: 'html';
  content: string;          // Raw HTML
}
```

### CountdownBlock

Renders as an animated GIF, which requires the Templatical Cloud backend -- `@templatical/renderer` emits a `templatical:unrenderable-block` marker instead. See [Blocks with no renderer](/backend/render#blocks-with-no-renderer).

```ts
interface CountdownBlock extends BaseBlock {
  type: 'countdown';
  targetDate: string;       // Local date/time, 'YYYY-MM-DDTHH:mm'
  timezone: string;         // IANA name the target is read in, e.g. 'Europe/Berlin'
  showDays: boolean;
  showHours: boolean;
  showMinutes: boolean;
  showSeconds: boolean;
  separator: ':' | '-' | ' ';
  digitFontSize: number;
  digitColor: string;
  labelColor: string;
  labelFontSize: number;
  backgroundColor: string;
  fontFamily?: string;
  labelDays: string;
  labelHours: string;
  labelMinutes: string;
  labelSeconds: string;
  expiredMessage: string;
  expiredImageUrl: string;  // Shown instead of the timer once expired
  hideOnExpiry: boolean;
}
```

### CustomBlock

```ts
interface CustomBlock extends BaseBlock {
  type: 'custom';
  customType: string;
  fieldValues: Record<string, unknown>;
  renderedHtml?: string;
  dataSourceFetched?: boolean;
}

interface SlotBlock extends BaseBlock {
  type: 'slot';
}

interface WrapperBlock extends BaseBlock {
  type: 'wrapper';
  children: Block[];
  borderRadius?: number;
}
```

`slot` and `wrapper` are layout markers. The skill never emits them. See [Layout](/guide/layout).

## Configuration Types

### MergeTag

```ts
interface MergeTag {
  label: string;
  value: string;
  group?: string;        // picker-only grouping label
  description?: string;  // picker-only helper text
  sample?: string;       // preview-only example value; never written to MJML
}

interface MergeTagRequestContext {
  reason: 'insert' | 'edit';
  current?: MergeTag;    // on edit, when the token matches tags
}
```

### MergeTagsConfig

```ts
interface MergeTagsConfig {
  syntax?: SyntaxPresetName | SyntaxPreset;
  tags?: MergeTag[];
  onRequest?: (context?: MergeTagRequestContext) => Promise<MergeTag | null>;
  showRawValue?: boolean;   // reveal raw token in tooltip (default true)
  autocomplete?: boolean;   // typing-based autocomplete (default true)
}

type SyntaxPresetName = 'liquid' | 'handlebars' | 'mailchimp' | 'ampscript';
```

### DisplayCondition

```ts
interface DisplayCondition {
  label: string;
  before: string;
  after: string;
  group?: string;
  description?: string;
}
```

### DisplayConditionsConfig

```ts
interface DisplayConditionsConfig {
  conditions: DisplayCondition[];
  allowCustom?: boolean;
}
```

### CustomBlockDefinition

```ts
interface CustomBlockDefinition {
  type: string;
  name: string;
  icon?: string;
  description?: string;
  fields: CustomBlockField[];
  template: string;                       // Liquid template
  dataSource?: DataSourceConfig;
  defaultStyles?: Partial<BlockStyles>;   // styles applied to new instances
  stylesheet?: string;                    // CSS emitted once into mj-head
}
```

See [Custom Blocks](/guide/custom-blocks) for field type details.

### ThemeOverrides

```ts
interface ThemeOverrides {
  bg?: string;
  bgElevated?: string;
  bgHover?: string;
  bgActive?: string;
  border?: string;
  borderLight?: string;
  text?: string;
  textMuted?: string;
  textDim?: string;
  primary?: string;
  primaryHover?: string;
  primaryLight?: string;
  secondary?: string;
  secondaryHover?: string;
  secondaryLight?: string;
  success?: string;
  successLight?: string;
  warning?: string;
  warningLight?: string;
  danger?: string;
  dangerLight?: string;
  canvasBg?: string;
  dark?: Omit<ThemeOverrides, 'dark'>;
}
```

### FontsConfig

```ts
interface FontsConfig {
  defaultFallback?: string;
  defaultFont?: string;
  customFonts?: CustomFont[];
  builtIns?: boolean | string[];  // true/omitted: all seven; false: none; string[]: allowlist — see /guide/fonts
}

interface CustomFont {
  name: string;
  url: string;
  fallback?: string;
}
```

### ViewportSize

```ts
type ViewportSize = 'desktop' | 'mobile';
```

## Factory Functions

All factory functions accept an optional partial override object and return a complete block with a generated ID.

```ts
import {
  createTitleBlock,
  createParagraphBlock,
  createImageBlock,
  createButtonBlock,
  createSectionBlock,
  createDividerBlock,
  createVideoBlock,
  createSpacerBlock,
  createSocialIconsBlock,
  createMenuBlock,
  createTableBlock,
  createHtmlBlock,
  createCountdownBlock,
  createCustomBlock,
  createBlock,
  cloneBlock,
  createDefaultTemplateContent,
  generateId,
} from '@templatical/types';

// Countdown GIFs need Cloud or a `blockRenderers.countdown` override; the OSS renderer emits a placeholder.

// Create with defaults
const paragraph = createParagraphBlock();

// Create with overrides
const heading = createTitleBlock({
  content: '<h1>Hello</h1>',
  level: 1,
});

// Create any block by type string
const block = createBlock('button');

// Deep clone with new ID
const copy = cloneBlock(existingBlock);

// Empty template
const template = createDefaultTemplateContent();

// Generate a UUID
const id = generateId();
```

## Type Guards

```ts
import {
  isTitle, isParagraph, isImage, isButton, isSection,
  isDivider, isVideo, isSpacer, isSocialIcons,
  isMenu, isTable, isHtml, isCountdown, isCustomBlock,
} from '@templatical/types';

if (isTitle(block)) {
  console.log(block.level); // TypeScript knows this is TitleBlock
}
```

## EventEmitter

A typed event emitter for subscription patterns.

```ts
import { EventEmitter } from '@templatical/types';

type Events = {
  change: TemplateContent;
  select: string | null;
};

const emitter = new EventEmitter<Events>();

const unsubscribe = emitter.on('change', (content) => {
  console.log('Changed:', content);
});

emitter.emit('change', templateContent);
unsubscribe(); // Remove listener
```

### Methods

| Method | Description |
|--------|-------------|
| `on(event, handler)` | Subscribe. Returns unsubscribe function |
| `off(event, handler)` | Unsubscribe a specific handler |
| `emit(event, data)` | Emit an event |
| `removeAllListeners(event?)` | Remove all listeners, optionally for a specific event |
| `listenerCount(event)` | Number of listeners for an event |

## Storage contracts

`Template`, `TemplatePatch`, `SavedBlockInput`, `CommentsProvider`, `MediaProvider` and the rest of the BYO storage shapes live under [Connect your backend](/backend/). This page is the block model and editor config.

---

# Comments

Source: https://docs.templatical.com/backend/comments

Give the editor a place to read and write comments from and it grows a review panel: threads with replies, per-block anchors, resolve and reopen, and a count badge on every commented block in the canvas.

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  templates: myTemplatesProvider,
  user: { id: 'u_7', name: 'Ada Lovelace' },
  comments: {
    list: async (templateId) => {
      const res = await fetch(`/api/templates/${templateId}/comments`);
      return res.json();
    },

    create: async (templateId, input) => {
      const res = await fetch(`/api/templates/${templateId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      return res.json();
    },

    update: async (templateId, commentId, patch) => {
      const res = await fetch(
        `/api/templates/${templateId}/comments/${commentId}`,
        {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(patch),
        },
      );
      return res.json();
    },
    delete: async (templateId, commentId) => {
      await fetch(`/api/templates/${templateId}/comments/${commentId}`, {
        method: 'DELETE',
      });
    },
    setResolved: async (templateId, commentId, resolved) => {
      const res = await fetch(
        `/api/templates/${templateId}/comments/${commentId}/resolve`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ resolved }),
        },
      );
      return res.json();
    },
  },
});
```

**Omitted by default.** With no provider the panel, its trigger and the per-block indicators do not render, and none of that UI is downloaded.

Comments are scoped to a template id, so the panel appears only once `create()` or `load()` has attached one. See [Saving & Loading](/backend/templates).


## Author identity

`user` is a **top-level key**, and comments require it. Without an author the feature reports itself unavailable rather than writing an anonymous comment: no trigger, no panel, no indicators.

```ts
init({ container, user: { id: 'u_7', name: 'Ada Lovelace' } });
```

`user.id` is compared against each comment's `author.id` to decide what may be edited or deleted. `user.name` is shown on comments this session writes.

::: tip Why it sits outside the provider
Collaboration presence needs the same value. A copy inside the comments provider would be the first thing to drift once a second feature wants it.
:::

::: warning Not a security boundary
`user` identifies the person to the editor's UI, in the user's own browser. Attribute writes server-side, from the session your backend already trusts. Nothing here stops a browser claiming a different name.
:::

## The contract

```ts
interface Comment {
  id: string;
  body: string;
  author: { id: string; name: string };
  createdAt: string;
  updatedAt?: string;
  blockId?: string | null;
  parentId?: string | null;
  resolvedAt?: string | null;
  resolvedBy?: { id: string; name: string } | null;
  replies?: Comment[];
}

interface CommentInput {
  body: string;
  blockId?: string;   // omitted for a template-level comment
  parentId?: string;  // omitted for a thread root
}

type CommentPatch = Partial<{ body: string }>;

interface CommentsProvider {
  list(templateId: string, params?: CommentsListParams): Promise<Comment[]>;
  create:      false | ((templateId: string, input: CommentInput) => Promise<Comment>);
  update:      false | ((templateId: string, commentId: string, patch: CommentPatch) => Promise<Comment>);
  delete:      false | ((templateId: string, commentId: string) => Promise<void>);
  setResolved: false | ((templateId: string, commentId: string, resolved: boolean) => Promise<Comment>);
  subscribe?:  (templateId: string, onChange: (change: CommentChange) => void) => () => void;
}
```

`list` cannot be turned off — without it the panel has nothing to show. Each of the four mutations takes `false` in place of a function: `false` states that the action is unavailable, and the editor hides it rather than disabling it. See [Read-only review](#read-only-review).

The editor renders `list()`'s order verbatim and never re-sorts. Ordering is your store's call.

### Thread depth

One level. A thread root carries `replies`; a reply never does. Flatten deeper trees in your store.

### `updatedAt`

Present means edited — the panel shows an *(edited)* marker. Set it on edit, **not on creation**: a store that stamps it alongside `createdAt` marks every comment edited.

### `setResolved`

Takes the target state, not a toggle. The call is idempotent, so two clicks in flight cannot leave a thread inverted and your endpoint never has to read current state before writing.

The editor reports the result your store returned, not the state it asked for. A store that refuses to reopen a thread answers "still resolved", and that is what the UI and [`onResolved`](#events) report.

## Read-only review

Withhold all four mutations and the panel stays readable and navigable. Threads and replies render, jump-to-block works, and the composer, resolve, edit and delete affordances are **absent** rather than disabled:

```ts
comments: {
  list: (templateId) => api.comments(templateId),
  create: false,
  update: false,
  delete: false,
  setResolved: false,
}
```

Each is independent: `setResolved: false` alone leaves commenting and editing intact with nothing to resolve; `update: false` alone removes only the pencil.

A programmatic call to a withheld mutation **rejects** rather than resolving — a resolved promise reads as "saved" to whoever awaited it.

Per-comment ownership composes on top: edit and delete are offered only on the current user's own comments, and only where the store supplied those mutations.

## Realtime updates

`subscribe` pushes remote changes into the open panel so a colleague's comment appears without a reload. **Optional** — without it, comments work the same and someone else's appears on the next read.

```ts
subscribe: (templateId, onChange) => {
  const source = new EventSource(`/api/templates/${templateId}/comments/stream`);
  source.onmessage = (event) => onChange(JSON.parse(event.data));
  return () => source.close();
},
```

Return an unsubscribe function; the editor calls it when the template changes and on teardown.

```ts
type CommentChange =
  | { type: 'created'; comment: Comment }
  | { type: 'updated'; comment: Comment }
  | { type: 'deleted'; commentId: string; parentId?: string | null };
```

A delete carries only the id and its parent — there is no comment left to send, and the parent saves the editor a lookup.

Your own writes may echo back through here, and need **no de-duplication on your side**: a `created` for a comment already in the list is ignored, and an `updated` replaces it in place.

## Filtering

The panel filters **in memory** over whatever `list()` returned — unresolved (the default), all, or this block. Your provider decides what is visible; the editor decides how it is narrowed within that.

`CommentsListParams` is reserved for future *filters* and is empty today; the editor always calls `list` bare.

::: tip Comments are not paginated
`list` returns every thread at once. The unresolved badge and the per-block canvas counts are derived over the whole list, so a partial page would make both under-report silently — wrong rather than slow. A long-lived template caps its own growth by having `list()` stop returning resolved threads past some age; the panel hides those by default anyway. [Version history](/backend/version-history#pagination) does page, because its list is a flat menu with nothing aggregating over it.
:::

## In the editor

- **A Comments button** in the header, badged with the unresolved thread count. It appears once a template is loaded and the feature is available.
- **A comment indicator on each commented block**, showing that block's count. Clicking it opens the panel filtered to that block.
- **The panel** on the right: thread cards with author, relative time, an *(edited)* marker, the resolve toggle, and reply / edit / delete where the store allows them.
- **A "missing block" badge** on a comment whose anchor block no longer exists, so an orphaned thread reads as orphaned rather than as a mystery.

## Events

```ts
comments: {
  // ...list, create, update, delete, setResolved
  onCreated:    (comment, { origin }) => {},
  onUpdated:    (comment, { origin }) => {},
  onDeleted:    (comment, { origin }) => {},
  onResolved:   (comment, { origin }) => {},
  onUnresolved: (comment, { origin }) => {},
}
```

`origin` is `'local'` for a write this editor made — `create`, `update`, `delete` or `setResolved`, called through the editor's own UI or `useComments`. It is `'remote'` for a change that arrived through [`subscribe`](#realtime-updates): someone else, in another browser.

A "new comments" badge outside the editor should count `remote` only. Counting `local` too increments a user's own unread count on their own comment.

Which of `onResolved` / `onUnresolved` fires is decided by the **stored** result's `resolvedAt`, not the state the call requested — a store that refuses to reopen a thread still reports `onResolved`.

::: tip Handlers usually fire once per change
A transport that echoes a write back to its sender — `subscribe`'s contract permits it — is compared against the stored comment: an echo that changes nothing is applied silently, with no event.

That bound holds for an echo that lands **after** the mutation's own response. One that arrives first applies as `origin: 'remote'`, and the local call still emits its own `origin: 'local'` event once its response settles — two events for one write, the first attributed to the wrong origin. Keep de-duplicating on your side if your transport can echo that early.

**Templatical Cloud is unaffected.** `createCloudCommentsProvider` stamps every write with an `X-Socket-ID` header, and Cloud's backend excludes that connection when it fans the change back out, so no early echo reaches this editor.
:::

A handler that throws is caught and reported to `onError` — it never fails the write that triggered it.

## Headless use

`useComments` from `@templatical/core` is the reactive state on its own, without the editor:

```ts
import { useComments } from '@templatical/core';

const comments = useComments({
  provider: myCommentsProvider,
  getTemplateId: () => currentTemplateId,
  getUser: () => currentUser,
  onError: (error) => console.error(error),
});

await comments.load();
comments.comments.value;                    // Comment[] — thread roots with replies
comments.unresolvedCount.value;             // number
comments.commentCountByBlock.value;         // Map<string, number>
await comments.create({ body: 'Looks good' });
await comments.setResolved('c_1', true);
```

The handlers from [Events](#events) are members of `myCommentsProvider` itself — the same object `list` / `create` / `update` / `delete` / `setResolved` live on — so one object satisfies `init()` and `useComments` alike.

`useCommentListener` wires a provider's `subscribe` into the same state, and is a no-op for a provider without one:

```ts
import { useCommentListener } from '@templatical/core';

useCommentListener({
  comments,
  provider: myCommentsProvider,
  getTemplateId: () => currentTemplateId,
});
```

---

# Connect your backend

Source: https://docs.templatical.com/backend/

The editor edits a template. Where that template is stored, what its past looks like, who commented on it, where a test send goes, how it becomes sending-ready HTML — all of that lives in your stack, and the editor reaches it through a plain object you pass to `init()`.

That object is a **provider**. They are all optional, and they all work the same way.

## The providers

```ts
import { init } from '@templatical/editor';

await init({
  container: '#editor',

  templates: myTemplateStore, // save and load
  versionHistory: myVersionStore, // past states — browse, preview, restore
  comments: myCommentStore, // threaded review, anchored per block
  savedBlocks: myBlockLibrary, // reusable groups of blocks
  media: myGallery, // images, folders, upload
  testEmail: mySender, // mail this template to a person
  render: myRenderer, // MJML and HTML output
});
```

Each key stands alone, and a feature is **absent until you pass its key**: no `versionHistory`, no history control — not a disabled button, not an empty panel, and none of that UI is downloaded either.

`init({ container })` on its own is a working editor that persists nothing.

## Provider keys

<!-- prettier-ignore -->
| Provider | The editor gives you | You implement |
| --- | --- | --- |
| [Saving & loading](/backend/templates) | inline name field, save button, save status, `Cmd`/`Ctrl`+`S`, autosave, unsaved-changes guard | `load` · `create` · `save` |
| [Version history](/backend/version-history) | header control, version list, preview on the canvas with its own banner, restore with confirmation | `list` · `get` · `create` · `restore` |
| [Comments](/backend/comments) | review panel, threads and replies, per-block count badges, resolve and reopen | `list` · `create` · `update` · `delete` · `setResolved` |
| [Saved blocks](/backend/saved-blocks) | pick session on the canvas, searchable browser with live preview, insert-at-position, rename, delete | `list` · `create` · `update` · `delete` |
| [Media](/backend/media) | Browse on image fields, drop-to-upload, crop, folders, search | `list` · `create` · `update` · `delete` · `folders` · `replace` · `importFromUrl` · `checkUsage` · `frequentlyUsed` · `storage` |
| [Test emails](/backend/test-email) | header trigger, recipient control, shape validation, accurate preview, sending and error states | `send` |
| [Rendering & export](/backend/render) | `toMjml()` and `toHtml()`, custom blocks pre-resolved, fonts resolved | any of `toMjml` · `toHtml` · `compileMjml` |

The editor keeps what is fiddly and the same for everybody: dirty tracking, a debounced autosave that pauses during undo, a preview that honours display conditions, the confirmation before a restore discards unsaved work. You keep where the bytes go, who may read them, and what your API looks like.

On `templates`, `versionHistory`, `comments`, `savedBlocks` and `media` every mutation is `false | fn`. Passing `false` states that the action is unavailable: calling it rejects, and wherever the editor renders a control for it, that control is hidden rather than disabled. Each provider page covers its own — [saved blocks](/backend/saved-blocks#controlling-permissions) and [media](/backend/media#controlling-permissions) have the fullest treatment. `render` and `testEmail` are shaped differently: every `render` method is independently optional, and `testEmail` is a single `send`.

::: warning Not a security boundary
Providers run in the user's browser. These flags shape the UI; they do not protect your API. Who may open a template, who may delete a shared saved block, which address a test may reach — enforce all of it on your server as well.
:::

## IDs

Ids come back from your `create()`; the editor never invents one. A template id is whatever your storage already calls it — a database key, a slug, a document id.

That id is also the join key. Version history and comments are both scoped to a template, so their controls appear only once `create()` or `load()` has attached one.

## Errors

Every provider method may reject. The editor reports the error through `onError`, surfaces it where the user is looking — the save status, the dialog they are in — and **leaves its own state untouched**. Nothing is marked saved that wasn't, and a failed delete does not make an entry disappear from the list.

Several of those messages reach the UI verbatim, so write them for the person who will read them.

## Callbacks

Two more seams reach your backend, but as plain functions rather than provider objects — nothing to withhold, no ids, no `false`:

```ts
type OnRequestMedia = (context?: MediaRequestContext) => Promise<MediaResult | null>;
type ResolvePreview = (context: PreviewResolveContext) => Promise<TemplateContent>;
```

- **`onRequestMedia`** is a **UI override** for the media picker (Bynder, Cloudinary widget, a host modal). It is not the store — that is the [`media`](/backend/media) provider. When both are set, the callback wins. Documented with the rest of image handling in [Images](/guide/images).
- **`resolvePreview`** hands the template to your backend and renders what comes back, so a preview shows real recipient data instead of merge-tag labels. Display-only: the result reaches preview surfaces and is never saved, sent or exported. See [Preview rendering](/guide/preview-rendering).

## Headless use

`useSavedBlocks`, `useVersionHistory` and `useComments` are exported from `@templatical/core`, so a provider can drive your own interface with no editor mounted at all. Media state is modal-scoped: `useMediaLibrary` lives in `@templatical/media-library`, and the provider methods themselves are the headless API. Each page's *Headless use* section has the surface.

---

# Media

Source: https://docs.templatical.com/backend/media

The editor owns the picker: Browse on image fields, video thumbnails and custom-block image fields, drag-and-drop upload, crop, folders, search. **You own storage.**

`onRequestMedia` is a separate seam — a UI override for a host widget (Bynder, Cloudinary, a modal of your own). It is not this store. When both are set, the callback wins and the built-in modal never opens. See [Images](/guide/images).

## Quick start

The bundled browser-local provider needs no backend:

```js
import { init, createLocalStorageMediaProvider } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  media: createLocalStorageMediaProvider(),
});
```

Entries go into `localStorage` under `templatical:media`. `create` stores the file as a data URL — a demo-sized model. `localStorage` quotas are typically around 5 MB, and a few large images will fill it. Folders, replace, import, usage, frequently-used and quota are `false`. Supply a provider for anything beyond demos, prototypes and a single device.

## Installation

::: warning Only needed standalone
Do not install [`@templatical/media-library`](/getting-started/installation#package-overview) next to the editor. `init({ media })` and Cloud's store already ship Browse as a lazy chunk; Vue is not installed. This package is the standalone SDK — `init()`, `useMediaLibrary`, or `MediaLibraryModal` in your own Vue app.
:::

::: code-group

```bash [npm]
npm install @templatical/media-library
```

```bash [pnpm]
pnpm add @templatical/media-library
```

```bash [yarn]
yarn add @templatical/media-library
```

```bash [bun]
bun add @templatical/media-library
```

:::

That package takes `vue` as a peer. Skip it when:

- you pass `onRequestMedia` and no `media` key — the host widget never mounts the modal
- you omit `media` entirely — image fields stay URL-only
- you call provider methods yourself — they are ordinary functions

::: tip Loading the editor from the CDN?
There is nothing to install. The CDN editor inlines the same chunk that the npm editor ships.
:::

## The contract

`media` takes any object implementing `MediaProvider`. `list` is a method; every other member is **either a function or `false`**:

```ts
interface MediaProvider {
  list(params?: MediaListParams): Promise<MediaListPage>;

  create: false | ((input: MediaCreateInput) => Promise<MediaAsset>);
  update: false | ((id: string, patch: MediaAssetPatch) => Promise<MediaAsset>);
  delete: false | ((ids: string[]) => Promise<void>);
  folders: false | MediaFoldersProvider;
  replace: false | ((id: string, file: File) => Promise<MediaAsset>);
  importFromUrl:
    | false
    | ((url: string, folderId?: string | null, templateId?: string) => Promise<MediaAsset>);
  checkUsage: false | ((ids: string[]) => Promise<Record<string, MediaUsageInfo>>);
  frequentlyUsed: false | (() => Promise<MediaAsset[]>);
  storage: false | (() => Promise<MediaStorageInfo | null>);
}

interface MediaFoldersProvider {
  list(): Promise<MediaFolder[]>; // FLAT; the UI trees via parentId
  create: false | ((input: MediaFolderInput) => Promise<MediaFolder>);
  update: false | ((id: string, patch: { name: string }) => Promise<MediaFolder>);
  delete: false | ((id: string) => Promise<void>);
  move: false | ((ids: string[], folderId: string | null) => Promise<MediaAsset[]>);
}
```

`false` means the current user may not perform that action, and the editor hides the affordance.

`list` cannot be `false`: without it the picker has nothing to show. Search, folder, category and cursor are sent on every listing — galleries outgrow one response. A provider that returns everything at once omits `nextCursor`.

`delete` and `checkUsage` are bulk: the grid is multi-select. `folders` is nested so a gallery without folders writes `folders: false` once; `folders.list()` returns a **flat** array and the UI trees it via `parentId`.

Cropping is client-side. Persist a crop by calling `create` or `replace` with the resulting `File`. There is no crop method on the provider, and no upload-progress callback: `create` takes a `File` and resolves with the asset.

Confirm always inserts `asset.url`. The grid uses `thumbnailUrl`, falling back to `url`. There is no conversion set (`small` / `medium` / `large`) on `MediaAsset` — if a derivative belongs in the email, put that URL on `url`.

### The data shape

```ts
interface MediaAsset {
  id: string;              // assigned by the provider, returned from create()
  url: string;             // lands on the block
  alt?: string;
  filename?: string;
  mimeType?: string;
  width?: number;
  height?: number;
  size?: number;           // bytes
  thumbnailUrl?: string;   // grid; falls back to url
  folderId?: string | null;
  canUpdate?: boolean;     // optional — absent means allowed; set false to forbid
  canDelete?: boolean;
  createdAt?: string;      // optional — display only, never affects ordering
  updatedAt?: string;
}

interface MediaCreateInput {
  file: File;
  folderId?: string | null;
  alt?: string;
  filename?: string;
  templateId?: string;
}

interface MediaAssetPatch {
  alt?: string;
  filename?: string;
}

interface MediaListParams {
  search?: string;
  cursor?: string;
  folderId?: string | null;
  category?: MediaCategory;   // "images" | "documents" | "videos" | "audio"
  templateId?: string;        // when a template is loaded; Cloud ignores
}

interface MediaListPage {
  items: MediaAsset[];
  nextCursor?: string;
}
```

- **`id` comes from the provider.** The editor never generates one; it uses whatever `create()` returns.
- **`templateId` is opportunistic.** Passed on `list` / `create` / `importFromUrl` when a template is loaded; omitted on a blank canvas. Media is not gated on it. Cloud ignores it; a CMS that scopes a gallery per template reads it here.
- **Ordering comes from the provider.** The editor renders `list()`'s order and never re-sorts.
- **Timestamps are display only.** Omit both and no label renders.
- **`maxFileSize` / `mimeTypes`** on the provider are a client pre-check, not a security boundary — the backend must enforce too.

```ts
interface MediaOptions {
  maxFileSize?: number; // bytes
  mimeTypes?: Partial<Record<MediaCategory, string[]>>;
  onCreated?: (asset: MediaAsset) => void;
  onUpdated?: (asset: MediaAsset) => void;
  onDeleted?: (asset: MediaAsset) => void;
}
```

`MediaProvider` extends `MediaOptions`, so one object carries storage and events.

## Controlling permissions

**Withhold a whole capability** by passing `false` instead of a function. The editor hides what it can't do: no upload zone and no drop when `create` is off, no edit when `update` is off, no delete when `delete` is off, no folder tree when `folders` is off.

```ts
const media: MediaProvider = {
  list: ({ search, cursor, templateId }) =>
    myCms.page({ search, cursor, templateId }),
  create: ({ file }) => myCms.upload(file),
  update: false,
  delete: false,
  folders: false,
  replace: false,
  importFromUrl: false,
  checkUsage: false,
  frequentlyUsed: false,
  storage: false,
};
```

**Withhold a single entry** with `canUpdate` / `canDelete` on it. Absent means allowed, so set them only on the exceptions. The two levers compose in one direction: `canUpdate: true` cannot re-enable an `update` the provider passed as `false`.

### A read-only library

Every mutation `false` gives a curated gallery users browse, search and pick from but never modify:

```ts
const media: MediaProvider = {
  list: async () => {
    const res = await fetch('/api/media');
    return json(res);
  },
  create: false,
  update: false,
  delete: false,
  folders: false,
  replace: false,
  importFromUrl: false,
  checkUsage: false,
  frequentlyUsed: false,
  storage: false,
};
```

Picking still works — it copies `{ url, alt }` onto the canvas, and nothing reaches the provider. Drop does not: drop needs `create`. `list` is the one member that can't be disabled.

::: warning Not a security boundary
Hiding a control stops the editor offering an action; it doesn't stop a determined caller. Provider methods run in the user's browser, so enforce permissions server-side too — including who may read, change or delete an asset in a library shared across a team.
:::

## Error handling

Any method may reject. The editor reports the failure through `onError` and leaves its in-memory list untouched, so a failed delete doesn't make an asset vanish from the UI. The modal stays open. Provider messages are user-presentable: they reach the UI verbatim.

Failed `create` does not prepend. `importFromUrl` keeps an inline field error.

## In the editor

The surface is a **picker**. There is no sidebar rail and no "manage media" chrome.

- **Browse** — an image field (and the video thumbnail, and a custom-block image field) shows a browse button when `media` **or** `onRequestMedia` is configured. With a `media` provider and no callback, the click opens the library modal. With `onRequestMedia`, the callback runs instead — the modal never mounts. Image fields and the video thumbnail pass `accept: ["images"]`; custom blocks may pass other categories. The modal forces `list({ category })` to that set and hides other tabs.
- **Confirm** — Confirm or double-click commits **one** previewed asset that matches `accept`, as `{ url: asset.url, alt: asset.alt }`. Multi-select is for bulk delete and move only. Close, Escape or backdrop returns `null`.
- **Drop** — dragging an image file onto an image block or field:

  | Config | Drop |
  | --- | --- |
  | `onRequestMedia` | yes — `context.files` |
  | provider, `create` is a function | yes — `provider.create({ file, templateId? })` |
  | provider, `create: false` | no |
  | neither | no |

  The drop zone pre-filters to `image/` MIME types. Size and type after that are `MediaOptions`, then the server. Don't return a `blob:` URL: `URL.createObjectURL(file)` is session-local and breaks export.
- **Lazy load** — omit `media` and none of the library UI is downloaded. `onRequestMedia` alone never mounts the modal.

## Off by default

Omit `media` and image fields stay URL-only, unless you passed `onRequestMedia`.

## Events

```ts
media: {
  // ...list, create, update, delete, …
  onCreated: (asset) => {},
  onUpdated: (asset) => {},
  onDeleted: (asset) => {},
}
```

Each fires once the matching mutation resolves, with the stored asset:

- **`onCreated`** after `create` or `importFromUrl`. A drop calls `create` and fires this without opening the modal and without prepending a listing row — the URL lands on the block.
- **`onUpdated`** after `update` or `replace`.
- **`onDeleted`** after `delete`.

There is no editor-level media list. The modal's own listing (when it is open) prepends, replaces and filters on success; a drop never touches that listing.

::: tip `onDeleted` carries the asset, not an id
`delete` resolves to nothing, so the handler receives the entry captured from the modal's loaded listing immediately before removing it.
:::

::: tip A delete outside the loaded listing fires no event
The captured entry has to already be in the modal's listing. Deleting an id that listing never held still deletes successfully; there is nothing to pass to `onDeleted`, so it does not fire.
:::

A handler that throws is caught and reported to `onError` — it never fails the create, update or delete that triggered it.

## Headless use

There is no `useMedia` in `@templatical/core`. Media state is modal-scoped; the provider methods **are** the headless API. Call them from your own UI, or drive the bundled modal's state machine:

```ts
import { useMediaLibrary } from '@templatical/media-library';

const {
  items,          // Ref<MediaAsset[]>
  isLoading,      // Ref<boolean>
  hasMore,        // Ref<boolean>
  loadItems,      // () => Promise<void>
  loadMore,       // () => Promise<void>
  uploadFile,     // (file: File) => Promise<MediaAsset | null>
  confirmDelete,  // () => Promise<void>
} = useMediaLibrary({
  provider,
  onError: (error) => {
    /* handle */
  },
});
```

It keeps the list in sync after each successful call — prepending on create, replacing on update, removing on delete — and reports to `onError` without mutating the list on failure. A stale `list` / `loadMore` response is discarded.


## Bringing your own

A minimal REST implementation:

```ts
import { init } from '@templatical/editor';
import type { MediaProvider } from '@templatical/editor';

const json = async (res: Response) => {
  if (!res.ok) throw new Error(`Media request failed: ${res.status}`);
  return res.json();
};

const media: MediaProvider = {
  list: async (params) => {
    const query = new URLSearchParams();
    if (params?.search) query.set('search', params.search);
    if (params?.cursor) query.set('cursor', params.cursor);
    if (params?.folderId) query.set('folderId', params.folderId);
    if (params?.category) query.set('category', params.category);
    if (params?.templateId) query.set('templateId', params.templateId);
    const res = await fetch(`/api/media?${query}`);
    return json(res);
  },

  create: async (input) => {
    const body = new FormData();
    body.append('file', input.file);
    if (input.folderId) body.append('folderId', input.folderId);
    if (input.templateId) body.append('templateId', input.templateId);
    const res = await fetch('/api/media', { method: 'POST', body });
    return json(res);
  },

  update: async (id, patch) => {
    const res = await fetch(`/api/media/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(patch),
    });
    return json(res);
  },

  delete: async (ids) => {
    const res = await fetch('/api/media', {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ids }),
    });
    if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
  },

  folders: false,
  replace: false,
  importFromUrl: false,
  checkUsage: false,
  frequentlyUsed: false,
  storage: false,
};

await init({ container: '#editor', media });
```

A CMS gallery that lists and uploads, and nothing else, is the same shape as the permissions example above. Shared and per-template galleries merge inside `list({ templateId })`.

---

# Rendering & Export

Source: https://docs.templatical.com/backend/render

Two methods on every editor instance:

```ts
const mjml = await editor.toMjml();
const html = await editor.toHtml();
```

## The two conversions

```
template JSON  ──▶  MJML  ──▶  HTML
```

**Template JSON → MJML** requires Templatical's block model: sections, columns, merge tags, display conditions, custom blocks. [`@templatical/renderer`](/api/renderer-typescript) does this, and it runs in the browser.

**MJML → HTML** requires an MJML compiler. It is generic — any compiler produces the same result, and none of them knows anything about Templatical. **The SDK does not bundle one**: compiling MJML is a separate, well-served concern and outside Templatical's scope, so `toHtml()` requires an implementation from you.

Each conversion can run in the browser or on your backend, which gives three arrangements:

<!-- prettier-ignore -->
| You provide | The SDK does | You get | Where `@templatical/renderer` runs |
| --- | --- | --- | --- |
| an MJML compiler endpoint | template → MJML, in the browser | `toMjml()` and `toHtml()` | **in your frontend bundle** |
| `toMjml` + `toHtml` | nothing | `toMjml()` and `toHtml()` | **on your backend**, if you use it there — never in the browser |
| nothing | template → MJML, in the browser | `toMjml()` only | **in your frontend bundle** |

## MJML → HTML on your backend

**You provide** one endpoint that takes MJML and returns HTML.
**The SDK** renders the template to MJML in the browser and hands it over.
**You get** `toMjml()` and `toHtml()`.
**You install** `@templatical/renderer` **in your frontend app**, next to the editor. Your backend needs only an MJML compiler — it never sees Templatical's block model.

Point one config key at any `mjml2html` endpoint and `toHtml()` starts working:

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  render: {
    compileMjml: async (mjml) => {
      const res = await fetch('/api/mjml', { method: 'POST', body: mjml });
      return res.text();
    },
  },
});

const html = await editor.toHtml();
```

Your endpoint performs only the second conversion, which makes this the smallest thing a backend can do and still produce HTML. `mjml2html(input)` is the entire implementation — a hosted compiler, a container or an `mjml` CLI shell-out all satisfy it equally.

That matters most off Node: implementing `toMjml` means running our TypeScript renderer somewhere, while implementing `compileMjml` from Laravel, Rails, Django or Go is a few lines against a tool that already exists.

## Template → MJML → HTML on your backend

**You provide** `toMjml` and `toHtml`, each taking the template and returning finished markup.
**The SDK** renders nothing — the browser produces no email markup at all.
**You get** `toMjml()` and `toHtml()`.
**You install** nothing in the frontend. Your backend needs something that turns the block model into MJML: `@templatical/renderer` **server-side** if it runs Node (see [Headless rendering](#headless-rendering)), or your own implementation in any other language.

```ts
const editor = await init({
  container: '#editor',
  render: {
    toMjml: async (payload) => {
      const res = await fetch('/api/render/mjml', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      return res.text();
    },

    toHtml: async (payload) => {
      const res = await fetch('/api/render/html', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      return res.text();
    },
  },
});
```

The package is never installed, imported or fetched: the local path sits behind a dynamic `import()` that only the fallback reaches, and a provider answering both calls means the fallback is never taken.

Pick this when your backend already renders email. You keep one renderer instead of two, and the MJML your users preview is the MJML you send.

`toMjml` alone moves the first conversion off the client. Pair it with `compileMjml` rather than `toHtml` if your backend produces MJML but leans on a separate tool to compile it.

## The contract

```ts
interface RenderPayload {
  /** Custom blocks already resolved into `renderedHtml`. */
  content: TemplateContent;
  fonts?: { customFonts: CustomFont[]; defaultFallback: string };
}

interface RenderProvider {
  toMjml?(payload: RenderPayload): Promise<string>;
  toHtml?(payload: RenderPayload): Promise<string>;
  compileMjml?(mjml: string): Promise<string>;
}
```

Every method is **independently optional**, and the editor resolves each on its own:

<!-- prettier-ignore -->
| Call | Order |
| --- | --- |
| `toMjml()` | `render.toMjml` → the local `@templatical/renderer` → reject |
| `toHtml()` | `render.toHtml` → `toMjml()`'s result + `render.compileMjml` → reject |

So the package requirement follows from **which methods you implement**, not from whether you configured `render` at all:

<!-- prettier-ignore -->
| Your provider | `@templatical/renderer` in the browser bundle? |
| --- | --- |
| *(no `render` key)* | **Yes** — `toMjml()` renders locally, `toHtml()` rejects |
| `{ compileMjml }` | **Yes** — the SDK renders the MJML, your endpoint compiles it |
| `{ toHtml }` | **Yes**, but only if you also call `toMjml()` |
| `{ toMjml }` | **No** — though `toHtml()` rejects |
| `{ toMjml, compileMjml }` | **No** |
| `{ toMjml, toHtml }` | **No** — nothing renders in the browser |

That table is about your **frontend** bundle. Whatever your backend uses to satisfy `toMjml` is a separate choice — often the same package, imported server-side.

`compileMjml` performs the second conversion only; something still has to produce the MJML, and without `render.toMjml` that is the local renderer. **`toMjml` is the method that moves rendering off the client.**

**There is no local HTML path.** With neither `toHtml` nor `compileMjml`, `toHtml()` rejects with an error naming the method to add, rather than guessing at a compiler that isn't there.

::: tip `toHtml()` composes through `toMjml()`
A provider supplying `toMjml` *and* `compileMjml` but not `toHtml` gets HTML built from **your** MJML, not the local renderer's. A backend that can render is authoritative and shouldn't be bypassed on the way to HTML.
:::

## The payload

A provider wins over the local renderer, so the editor hands over everything a backend cannot work out for itself. The payload is **render-complete**:

- **Custom blocks are already resolved.** `content` arrives with every custom block's `renderedHtml` filled in. Without this the failure is silent: a renderer given a custom block with neither a resolver nor `renderedHtml` **omits it from the output**. The HTML comes from your liquid template plus the block's field values, and the definition is registered in the browser, so there is nothing a server could do with it.
- **Fonts are resolved.** `fonts` carries the custom faces the editor is rendering with, plus the fallback stack for anything unmatched — assembled from `init({ fonts })`, which is not reconstructible from the template JSON.
- **`content` is a defensive copy.** Mutate it freely; the user's document is untouched.

## Template → MJML in the browser

**You provide** nothing.
**The SDK** renders the template to MJML in the browser.
**You get** `toMjml()`. `toHtml()` rejects, because no compiler is available.
**You install** `@templatical/renderer` **in your frontend app**.

With no `render` provider — or one implementing `compileMjml` alone — `toMjml()` uses [`@templatical/renderer`](/api/renderer-typescript), an optional peer dependency, MIT-licensed. Install it where you export from:

::: code-group

```bash [npm]
npm install @templatical/renderer
```

```bash [pnpm]
pnpm add @templatical/renderer
```

```bash [yarn]
yarn add @templatical/renderer
```

```bash [bun]
bun add @templatical/renderer
```

:::

`toMjml()` lazy-imports it on first call and rejects with a clear error naming the package if it isn't installed. Custom blocks resolve through the editor's own registry, and your configured fonts are wired in automatically.

::: tip Loading from the CDN?
There is nothing to install. The CDN build is self-contained, so `@templatical/renderer` ships with it as a code-split chunk that loads on the first `toMjml()` call.
:::

### Wrapping the bundled renderer

`toMjml` replaces the local render, and one thing it can replace it with is the local render plus your own additions. Chrome every email must carry — an Impressum, an unsubscribe block, a legal footer — then composes as ordinary blocks instead of string surgery on the output:

```ts
import { renderToMjml } from '@templatical/renderer';

const editor = await init({
  container,
  render: {
    toMjml: ({ content, fonts }) =>
      renderToMjml(
        {
          settings: { ...content.settings, backgroundColor: '#f3f4f6' },
          blocks: [...headerBlocks, ...content.blocks, ...footerBlocks],
        },
        {
          customFonts: fonts?.customFonts,
          defaultFallbackFont: fonts?.defaultFallback,
        },
      ),
  },
});
```

`headerBlocks` and `footerBlocks` are plain `Block[]` — author them in the editor and export the JSON. Because they never enter editor state, they cannot be selected, edited, undone or saved, and `getContent()` returns the template without them. `toHtml()` composes through `toMjml()`, so both export paths carry them, as does the test-email dialog's `includeMjml` payload.

What to watch for:

- This is the one arrangement where implementing `toMjml` still puts `@templatical/renderer` in your frontend bundle. The table above describes providers that render elsewhere; here you are calling the local renderer yourself.
- `getCustomBlockStylesheet` is not part of the payload. Pass your own resolver if your custom blocks carry definition-level CSS — otherwise their `<mj-style>` rules are dropped.
- `initCloud()` ignores `render`, so this is an `init()` arrangement.

::: tip Showing it in the preview too
The canvas shows the template, not the chrome around it. [`resolvePreview`](/guide/preview-rendering#show-what-your-platform-appends-at-send-time) is what puts it there — by calling the same composition function as above, or by asking the backend that appends for real.
:::

## Headless rendering

Outside the editor, call the renderer directly:

```ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(content, {
  renderCustomBlock: async (block) => myLiquid.render(block),
});
```

### Overriding a block type

`blockRenderers` replaces the built-in renderer for a given `block.type`:

```ts
const mjml = await renderToMjml(content, {
  blockRenderers: {
    countdown: (block) => `<mj-image src="${countdownGifUrl(block)}" />`,
    video: (block, ctx) => renderVideoWithPlayButton(block, ctx),
  },
});
```

It generalises `renderCustomBlock` from one block type to any. A backend whose output is a *superset* of the browser's can inject exactly that delta instead of forking the renderer, and parity for every other block type then holds by construction. Templatical Cloud uses it for two blocks: a server-generated animated countdown GIF and a composited video play button.

An override owns everything the built-in did, including the hidden-on-all-viewports early return.

### Blocks with no renderer

A block type with neither a built-in renderer nor a `blockRenderers` override emits a placeholder comment and logs a warning:

```html
<mj-raw><!-- templatical:unrenderable-block type="countdown" id="0192…" --></mj-raw>
```

`countdown` is the only built-in block that lands here today. A block hidden on every viewport still renders nothing and warns about nothing, since that is what its author asked for.

::: tip Why a marker and not a throw
The renderer runs inside send pipelines, where killing an entire render over one block is worse than shipping a marked gap. Silence is worse still: a countdown vanishing from a marketing email reaches recipients as a missing section with nothing explaining why. The marker is greppable, so a pipeline can refuse to ship one.
:::

Both halves are exported, so nothing has to hardcode the marker text:

```ts
import {
  UNRENDERABLE_MARKER_PREFIX,
  renderUnrenderableBlock,
} from "@templatical/renderer";

if (mjml.includes(UNRENDERABLE_MARKER_PREFIX)) {
  throw new Error("Refusing to send: a block in this template rendered as a gap.");
}
```

`UNRENDERABLE_MARKER_PREFIX` is the marker's stable leading text — scan for it before shipping. `renderUnrenderableBlock(block)` emits one and logs the warning, so a `blockRenderers` override can degrade the same way for a variant it cannot handle, instead of returning `""` and reintroducing a silent drop.

## Reference

- [`@templatical/renderer` API](/api/renderer-typescript)
- [Saving & Loading](/backend/templates) — the save/load lifecycle, separate from this
- [Custom Blocks](/guide/custom-blocks) — why pre-rendering is part of the payload

---

# Saved Blocks

Source: https://docs.templatical.com/backend/saved-blocks

Saved blocks let your users capture a group of blocks — a header, a footer, a product grid, a CTA — and re-insert it into any other template.

The editor owns the experience: a save action on every block, a searchable browser with live preview, insert-at-position, rename and delete. **You own storage.**

::: tip Not the same as custom blocks
[Custom blocks](/guide/custom-blocks) are *developer-defined block types* with their own template and fields. Saved blocks are *instances* of ordinary blocks that end users save and reuse. The two are independent.
:::

## Quick start

The bundled browser-local provider needs no backend:

```js
import { init, createLocalStorageSavedBlocksProvider } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  savedBlocks: createLocalStorageSavedBlocksProvider(),
});
```

Entries go into `localStorage` under `templatical:saved-blocks`. Good for demos, prototypes and single-device use — they live in one browser profile, don't sync, and disappear when site data is cleared. Supply a provider for anything beyond that.

## Bring your own storage

`savedBlocks` takes any object implementing `SavedBlocksProvider` — four members. `list` is a method; each mutation is **either a function or `false`**:

```ts
interface SavedBlocksProvider {
  list(params?: { search?: string; category?: string }): Promise<SavedBlock[]>;

  create: false | ((input: SavedBlockInput) => Promise<SavedBlock>);
  update: false | ((id: string, patch: SavedBlockPatch) => Promise<SavedBlock>);
  delete: false | ((id: string) => Promise<void>);
}
```

`false` means the current user may not perform that action, and the editor hides the affordance.

A minimal REST implementation:

```ts
import { init } from '@templatical/editor';
import type { SavedBlocksProvider } from '@templatical/editor';

const json = async (res: Response) => {
  if (!res.ok) throw new Error(`Saved blocks request failed: ${res.status}`);
  return res.json();
};

const savedBlocks: SavedBlocksProvider = {
  // Return everything the current user may see — scope it per user, team or
  // account here. The editor calls this with no arguments and filters in the
  // browser.
  list: async () => {
    const res = await fetch('/api/saved-blocks');
    return json(res);
  },

  create: async (input) => {
    const res = await fetch('/api/saved-blocks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(input),
    });
    return json(res);
  },

  update: async (id, patch) => {
    const res = await fetch(`/api/saved-blocks/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(patch),
    });
    return json(res);
  },

  delete: async (id) => {
    const res = await fetch(`/api/saved-blocks/${id}`, { method: 'DELETE' });
    if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
  },
};

await init({ container: '#editor', savedBlocks });
```

### The data shape

```ts
interface SavedBlock {
  id: string;            // assigned by the provider, returned from create()
  name: string;
  content: Block[];      // top-level blocks; a section carries its own children
  category?: string;     // optional — free-text grouping, drives the browser filter
  canUpdate?: boolean;   // optional — absent means allowed; set false to forbid
  canDelete?: boolean;
  createdAt?: string;    // optional — display only, never affects ordering
  updatedAt?: string;
}

interface SavedBlockInput {
  name: string;
  content: Block[];
  category?: string;
}

type SavedBlockPatch = Partial<{
  name: string;
  content: Block[];
  category: string; // "" clears the category; omitting the key leaves it
}>;
```

- **`id` comes from the provider.** The editor never generates one; it uses whatever `create()` returns.
- **Rename with `update(id, { name })`, recategorise with `update(id, { category })`.** There are no separate methods — `update` takes a partial patch.
- **Ordering comes from the provider.** The editor renders `list()`'s order and never re-sorts; filtering narrows without reordering. Sort server-side, before `list()` returns.
- **Filtering happens in the editor.** The search box and category filter run in memory over whatever `list()` returned. `list()` accepts `{ search, category }`, but the editor never sends it — that path exists for callers driving `useSavedBlocks` directly (see [Headless use](#headless-use)). Which entries a user may see at all is decided in `list()`.
- **Timestamps are display only.** Each entry shows a relative "5m ago" label (from `updatedAt`, falling back to `createdAt`) with the absolute date on hover. Omit both and no label renders.

## Controlling permissions

**Withhold a whole capability** by passing `false` instead of a function. The editor hides what it can't do: no bookmark action when `create` is off (so no save flow at all), no rename control when `update` is off, no delete control when `delete` is off.

```ts
const savedBlocks: SavedBlocksProvider = {
  list: async () => {
    const res = await fetch('/api/saved-blocks');
    return json(res);
  },

  // This user may add to the library but never change or remove what's there.
  create: (input) => post('/api/saved-blocks', input),
  update: false,
  delete: false,
};
```

**Withhold a single entry** with `canUpdate` / `canDelete` on it. Absent means allowed, so set them only on the exceptions:

```json
[
  { "id": "1", "name": "My header", "content": [] },
  { "id": "2", "name": "Team footer", "content": [], "canUpdate": false, "canDelete": false }
]
```

The user can edit their own entry and only insert the shared one. The two levers compose in one direction: `canUpdate: true` cannot re-enable an `update` the provider passed as `false`.

### A read-only library

All three `false` gives a curated library users browse, preview and insert from but never modify:

```ts
const savedBlocks: SavedBlocksProvider = {
  list: async () => {
    const res = await fetch('/api/saved-blocks');
    return json(res);
  },
  create: false,
  update: false,
  delete: false,
};
```

Insertion still works — it only touches the canvas, and nothing reaches the provider. `list` is the one member that can't be disabled.

::: warning Not a security boundary
Hiding a control stops the editor offering an action; it doesn't stop a determined caller. Provider methods run in the user's browser, so enforce permissions server-side too — including who may read, change or delete a block in a library shared across a team.
:::

## Error handling

Any method may reject. The editor reports the failure through `onError` and leaves its in-memory list untouched, so a failed delete doesn't make a block vanish from the UI. The save dialog also shows the rejection message inline.

## In the editor

- **Save** — selecting a top-level block reveals a bookmark action in its action bar, which starts a *pick session* with that block already picked.

  ![A selected block's action bar, with the bookmark action that starts a pick session](/images/saved-blocks-pick-start.png)

  During a session, plain clicks add or remove blocks and a bar over the canvas shows the count with Save and Cancel. Clicking inside a section picks the whole section — section children aren't individually savable, since a section carries its columns and their contents with it. Escape cancels, Enter confirms.

  ![A pick session on the canvas — two picked blocks outlined, with a floating bar below reading "2 block(s) selected" beside Cancel and Save Block](/images/saved-blocks-pick-selector.png)

  Confirming opens a dialog asking for a name and an optional **category**, suggesting the ones already in use. The preview lists blocks in pick order, each row draggable by its grip handle — or Arrow Up / Arrow Down with the handle focused. Whatever order the list ends in is the order the blocks are stored in.

  ![The Save as Block dialog — a name and a category field above a reorderable preview of the two picked blocks, each row with a grip handle](/images/saved-blocks-save.png)

- **Browse** — a saved-blocks entry sits in the left rail whenever the feature is configured, opening a searchable browser with a live preview. It renders from the first paint whether anything is saved or not, so the rail never shifts. Nothing is fetched until the browser or the save dialog opens; a first open shows placeholder rows until `list()` answers.

  ![The Browse Saved Blocks modal — search and a category filter above the entry list on the left, a live preview and the insert-position picker on the right](/images/saved-blocks-browse.png)

- **Categorise** — free text, flat, optional. No folders, no nesting. The category filter appears as soon as anything is categorised and lists exactly the categories in use. Search and the filter narrow together.
- **Insert** — choose a position (beginning, after any existing block, or end). Inserted blocks always get **fresh IDs**, so inserting the same entry twice never collides.
- **Rename / recategorise / delete** — inline on each row. The edit row covers name and category together, and clearing the category field uncategorises the entry. Delete asks for confirmation.

## Off by default

Omit `savedBlocks` and the feature is absent: no save action, no rail entry, and **none of its code is downloaded**. The UI is split into lazily-loaded chunks fetched only when a dialog opens.

## Events

```ts
savedBlocks: {
  // ...list, create, update, delete
  onCreated: (block) => {},
  onUpdated: (block) => {},
  onDeleted: (block) => {},
}
```

Each fires once the editor has applied the change to its own list: `onCreated` after a save prepends the new entry, `onUpdated` after an edit replaces it, `onDeleted` after a removal filters it out.

::: tip `onDeleted` carries the block, not an id
`delete` resolves to nothing, so the editor passes the entry it captured immediately before removing it.
:::

::: tip A delete outside the loaded list fires no event
The captured entry has to already be in the editor's list. Deleting an id the editor never loaded — a headless caller acting on an id from elsewhere — still deletes successfully; there is nothing to pass to `onDeleted`, so it does not fire.
:::

A handler that throws is caught and reported to `onError` — it never fails the create, update or delete that triggered it.

## Headless use

The reactive state layer is exported from `@templatical/core` if you want your own UI over a provider:

```ts
import { useSavedBlocks } from '@templatical/core';

const {
  savedBlocks, // Ref<SavedBlock[]>
  isLoading,   // Ref<boolean>
  categories,  // ComputedRef<string[]> — distinct categories, sorted
  canCreate,   // ComputedRef<boolean> — did the provider supply create?
  canUpdate,
  canDelete,
  canUpdateBlock, // (block) => boolean — capability AND the entry's own flag
  canDeleteBlock,
  load,        // (params?: { search?, category? }) => Promise<void>
  create,      // (name, content, category?) => Promise<SavedBlock>
  update,      // (id, patch) => Promise<SavedBlock>
  remove,      // (id) => Promise<void>
} = useSavedBlocks({
  provider,
  onError: (error) => {
    /* handle */
  },
});
```

It keeps the list in sync after each successful call — prepending on create, replacing on update, removing on delete — and re-throws after reporting to `onError`.

Check `canCreate` / `canUpdateBlock` / `canDeleteBlock` before offering an action in your own UI. Calling a withheld mutation — or one an entry forbids — rejects rather than silently resolving, so a caller can never mistake a refusal for a save.

---

# Saving & Loading Templates

Source: https://docs.templatical.com/backend/templates

Give the editor somewhere to save to and it grows the chrome that goes with it: an inline-editable template name, a save button, a save-status indicator, `Cmd`/`Ctrl`+`S`, optional autosave, and a warning before the tab closes with unsaved work.

The editor owns all of that. **You own persistence** — three methods against your own API.

## Quick start

```ts
import { init } from "@templatical/editor";

const editor = await init({
  container: "#editor",
  templates: {
    load: async (id) => {
      const res = await fetch(`/api/templates/${id}`);
      return res.json();
    },

    create: async (input) => {
      const res = await fetch("/api/templates", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(input),
      });
      return res.json();
    },

    save: async (id, patch) => {
      const res = await fetch(`/api/templates/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(patch),
      });
      return res.json();
    },
  },
});

// Opening a template is imperative — your app decides which one.
await editor.load("tpl_123");
```

**Omit `templates` and the feature is absent** — no name field, no save button, no status indicator. `create()` / `load()` / `save()` then reject with an explanatory error, and you persist the content yourself from [`onChange`](#persisting-without-a-provider).

## The contract

```ts
interface Template {
  id: string;
  name?: string;
  createdAt?: string;
  updatedAt?: string;
  content: TemplateContent;
}

type TemplatePatch = Partial<{ name: string; content: TemplateContent }>;

interface TemplatesProvider {
  load(id: string): Promise<Template>;
  create:
    | false
    | ((input: {
        name?: string;
        content: TemplateContent;
      }) => Promise<Template>);
  save: false | ((id: string, patch: TemplatePatch) => Promise<Template>);
}
```

- **`id` comes from your store**, returned by `create()`. The editor never generates one — a database key, a slug, a document id, whatever your storage already uses.
- **`save` receives a patch**, not bare content, so a rename can travel without content and a new field can be added without breaking your implementation. The editor sends `name` (when the template has one) and `content` together, in one round-trip.
- **`createdAt` / `updatedAt` are optional, ISO 8601, and display-only.** Both are absent from `TemplatePatch`, so the editor never writes them — what it shows is whatever `load` or `save` returned. See [The write time](#the-write-time).
- **`name` is optional.** With no name column, leave it out — the header renders a dimmed "Untitled" in its place. The field stays editable as long as `save` is a function, so a rename still travels as `save(id, { name, content })`. A store that ignores it returns a template with no name, and the header reverts to "Untitled". With no use for names at all, [hide the field](#hiding-the-name-field).
- **Persistence side effects belong in `save()`'s body.** It runs on every write, so logging, cache invalidation or a webhook call has one home. See [Events](#events) for what the editor exposes once that write has settled.

Every method may reject. The editor reports the failure through `onError`, shows it in the header, and leaves its state untouched — nothing is marked saved that wasn't.

### No list, no delete

The editor has no template browser. Choosing _which_ template to open belongs to your application; the editor's job starts once you hand it an id.

## Events

```ts
templates: {
  load, create, save,
  onSaved:   (template, { trigger }) => {},
  onCreated: (template) => {},
  onLoaded:  (template) => {},
}
```

`trigger` is one of `manual`, `autosave`, `rename`, `restore`, `api`. `manual` covers the header's Save button and `Cmd`/`Ctrl`+`S`.

::: tip Where post-save logic belongs
Persistence goes in `save()` — it is your function, and its body runs before the editor has adopted the response. `onSaved` is for the two things that body cannot give you: which affordance fired the save, and a point at which the editor has settled. Navigating from `save()` runs while `isDirty` is still `true`, so your own router guard blocks you.
:::

A handler that throws is caught and reported to `onError` — it never fails the save, create or load that triggered it.

## Disabling create or save

`create` and `save` take `false | fn`. `load` cannot be turned off — without it there is nothing to open.

```ts
templates: {
  load: (id) => fetchTemplate(id),
  create: false,  // editor.create() rejects
  save: false,    // read-only: loads and edits locally, persists nothing
}
```

**`save: false`** hides the save button and the status indicator, and makes the name read-only — there is nowhere for a change to go. `Cmd`/`Ctrl`+`S` and autosave become no-ops rather than errors. Loading a template and editing it locally still works.

**`create: false`** makes `editor.create()` reject. It hides nothing, because the editor has no create affordance of its own — creating is always your app calling `editor.create()`. You set the flag, so gate your own New-template control on the same value, or `try` / `catch` the call.

::: warning Not a security boundary
These flags live in the user's browser. They shape the UI; they do not protect your API. Enforce permissions server-side.
:::

## The header

<!-- prettier-ignore -->
| Where | What |
| --- | --- |
| left | the template name, click-to-edit, and the write time under it |
| right | the save status, then the save button |

The name commits on `Enter` or blur, cancels on `Escape`, and reverts an empty value — a cleared field is far likelier a slip than an intent. A rename is an ordinary unsaved change: it marks the editor dirty and persists on the next save, in the same patch as the content.

The status indicator has three states:

| State           | Shown when                                                       |
| --------------- | ---------------------------------------------------------------- |
| **Unsaved**     | there are edits the editor knows aren't persisted                |
| **Saved**       | a save just succeeded (for a few seconds)                        |
| **Save failed** | the last attempt rejected — your error message is in the tooltip |

The save button is disabled until a template exists, because `save()` patches an id. Call `create()` or `load()` first.

### The write time

A template carrying `updatedAt` renders a relative line under the name, with the full date on hover:

> Updated 5m ago

`createdAt` is the fallback when `updatedAt` is absent, and the wording follows whichever was used — a template your store has never rewritten reads "Created", never "Updated". Neither field, or a value that does not parse, renders nothing at all. The line refreshes while the editor stays open.

::: warning Do not stamp `updatedAt` in `create`
Set `createdAt` alone when a template is first written. Stamping both makes a brand-new template read **"Updated just now"** before anyone has edited it — the editor prefers `updatedAt` and labels it accordingly, so it is reporting exactly what your store claimed.

This is easy to hit by accident: a column default of `updated_at = created_at` on insert, or a single `now()` assigned to both, produces it. Let the first `save` be the first thing that sets `updatedAt`.
:::

::: tip
It renders whether or not `save` is available, which is what a read-only template has in place of a status indicator.
:::

### Hiding the name field

```ts
init({
  templates: {
    nameField: false,
    /* … */
  },
});
```

Removes the field from the header, whether or not your provider can save. `editor.create({ name })`, `setName()` and the `name` in each save patch keep working, so your own chrome can still manage names. `initCloud()` accepts the same key on its own `templates` object.

With the field hidden, the write time becomes the header's only left-column content.

## Autosave

```ts
await init({
  container: "#editor",
  templates: {
    autoSave: true,
    /* … */
  },
  changeDebounce: 5000, // defaults to 2000
});
```

`templates.autoSave` turns saving on. `changeDebounce` sets the cadence, at the config root rather than alongside it.

::: tip `changeDebounce` also paces `onChange`
The same timer drives both, and [`onChange`](#persisting-without-a-provider) fires whether or not `templates` is configured — so the cadence has to stay reachable from a config with no provider at all.
:::

The debounce restarts on every change, so a burst of typing produces one save. It pauses while the user steps through undo/redo, and skips the save entirely when nothing is dirty.

::: warning `autoSave` needs somewhere to save
`true` with a provider whose `save` is `false` logs a warning and saves nothing. Persist from `onChange` instead.
:::

## Cmd+S

`Cmd`/`Ctrl`+`S` always means "persist now":

- with a `templates` provider it calls `save()`;
- without one it flushes the `onChange` debounce immediately, so a consumer persisting from `onChange` still gets the keystroke.

## Unsaved changes

Two mechanisms, because neither covers the other:

```ts
await init({
  container: "#editor",
  templates: {
    unsavedChangesGuard: true, // the default
    /* … */
  },
  onDirtyChange: (isDirty) => {
    hasUnsavedWork.value = isDirty;
  },
});
```

**`templates.unsavedChangesGuard`** is a `beforeunload` prompt, on by default whenever a provider is configured. It covers closing or reloading the tab. Set it to `false` to own that prompt yourself. Without a provider the editor never warns — it has no way to know whether you already persisted the change.

**`onDirtyChange`** (and its pull-based twin `editor.isDirty()`) is what you guard a client-side router with, since `beforeunload` does not fire on an in-app navigation:

```ts
router.beforeEach((to, from, next) => {
  if (editor.isDirty() && !confirm("Discard unsaved changes?"))
    return next(false);
  next();
});
```

`onDirtyChange` works with or without a provider, at the config root either way. `initCloud()` accepts `unsavedChangesGuard` on its own `templates` object, on the same terms — Cloud always has a store to save to, so the guard is on there unless you refuse it.

## The instance API

```ts
const template = await editor.create({ name: "Welcome email" });
await editor.load(template.id);
await editor.save();
editor.isDirty(); // boolean
```

- **`create(input?)`** persists the current content as a new template. Pass `content` to replace the editor's content first, so `create({ content })` loads and stores in one step.
- **`load(id)`** fetches a template and makes it the editor's content, discarding local edits.
- **`save()`** persists the loaded template's name and content as one patch.

All three are always present on the type, and reject with an explanatory error when no provider is configured or the provider withheld the relevant method. Guard with `try` / `catch` if you call them from a button of your own. The header hides its own save controls when `save` is withheld, but `create()` and `load()` have no editor UI at all — a control for either is yours to gate.

## Persisting without a provider

A provider is not the only way to keep a template. `onChange` fires, debounced, whenever the content changes:

```ts
await init({
  container: "#editor",
  onChange: (content) => myStore.save(content),
});
```

`Cmd`/`Ctrl`+`S` flushes that debounce immediately, so the keystroke still reaches you. You then own the save button, the status and the dirty prompt as well. Use `onChange` when the editor's chrome isn't what you want; use the provider when it is.

## Reference

- [`init()` options](/api/editor)
- [Rendering & Export](/backend/render) — bring-your-own MJML/HTML rendering
- [Saved Blocks](/backend/saved-blocks) — the same bring-your-own-storage shape, for reusable groups of blocks
- [Test Emails](/backend/test-email) — bring-your-own sending

---

# Test Emails

Source: https://docs.templatical.com/backend/test-email

Let a user send themselves the template they're editing, so they can see it land in a real inbox before it goes anywhere near a campaign.

The editor owns the trigger, the dialog, recipient validation and the sending / success / error states. **You own delivery** — one method.

## Quick start

```ts
import { init } from '@templatical/editor';

await init({
  container: '#editor',
  testEmail: {
    send: async ({ recipient, content }) => {
      const res = await fetch('/api/test-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ recipient, content }),
      });
      if (!res.ok) throw new Error('Could not send the test email');
    },
  },
});
```

That's the whole integration. A **Test** button appears in the editor header; clicking it opens a dialog, and the address the user picks is handed to your `send`.

![The Test button in the top-right of the editor header](/images/test-email-button.png)

**Omit `testEmail` and the feature is absent** — no button, and none of its UI code is downloaded.

## The payload

```ts
interface TestEmailPayload {
  recipient: string;
  content: TemplateContent;      // always present
  mjml?: string;                 // only when `includeMjml` is set
  allowedRecipients?: string[];  // only when you configured one — untrusted, see below
}
```

Reject with a message and the dialog shows it inline and stays open, so the user can retry:

```ts
send: async ({ recipient, content }) => {
  const res = await fetch('/api/test-email', { /* … */ });
  if (res.status === 429) throw new Error('Too many test emails — try again in a minute.');
  if (!res.ok) throw new Error('Could not send the test email.');
}
```

The message reaches the user verbatim, so write it for them rather than logging a status code.

## Rendering to HTML

`content` is the template as JSON. Your mail service needs HTML, so compile it server-side with [`@templatical/renderer`](/api/renderer-typescript) plus an MJML compiler:

```ts
// On your server
import { renderToMjml } from '@templatical/renderer';
import mjml2html from 'mjml';

app.post('/api/test-email', async (req, res) => {
  const { recipient, content } = req.body;

  // Validate the recipient here — see "Restricting recipients" below.
  const mjml = await renderToMjml(content);
  const { html } = mjml2html(mjml);

  await mailer.send({ to: recipient, subject: 'Test email', html });
  res.sendStatus(204);
});
```

### `includeMjml`

Set it and the payload carries the MJML, so you don't call `renderToMjml` yourself:

```ts
testEmail: {
  includeMjml: true,
  send: async ({ recipient, mjml }) => { /* compile `mjml` → HTML and send */ },
}
```

This requires [`@templatical/renderer`](/api/renderer-typescript), an optional peer dependency. Two behaviours to know:

- **Not installed** — the send still happens with JSON only, `mjml` is absent, and the editor logs one warning naming the package. Opting in never breaks sending, so **always guard for `mjml` being undefined**.
- **Rendering fails** — a malformed custom block, say — the send is aborted and the error shows in the dialog. Mailing without the MJML would hide a broken template.

You compile MJML → HTML yourself either way. The editor never bundles an MJML compiler.

## Restricting recipients

By default the dialog accepts any address. Pass `allowedRecipients` to restrict it:

```ts
testEmail: {
  allowedRecipients: [currentUser.email, 'qa@acme.com'],
  send: async ({ recipient, content }) => { /* … */ },
}
```

| Value | The dialog shows |
| --- | --- |
| omitted | a free-text field, validated for shape |
| one entry | a read-only field, pre-filled |
| several | a picker of exactly those addresses |
| `[]` (empty) | nothing — the feature reports itself unavailable and **no button renders** |

An empty array reads as a decision ("nobody may be sent to"), not as "unset". Use `defaultRecipient` to pre-select an entry; it's ignored if it isn't on the list.

::: warning Not a security boundary
`allowedRecipients` lives in the user's browser and is trivially edited there. It restricts the *picker*, nothing more.

**Validate the recipient on your server**, every time. Without that, your endpoint is an open relay — anyone who can reach it can mail arbitrary addresses from your domain.
:::

The payload echoes the list back as `allowedRecipients` so one `send` implementation stays portable between your backend and Templatical Cloud. It is **untrusted** — unsigned, and read out of the browser. Beyond portability it is good for one thing: compare it against `recipient` server-side, where a mismatch means a tampered or buggy client and is worth logging.

## Events

```ts
testEmail: {
  send, // ...includeMjml, allowedRecipients, defaultRecipient
  onSent: (payload) => {},
}
```

Fires once `send` resolves, with the same `TestEmailPayload` it was given. Not called for a rejected send — that surfaces through the dialog's own inline error instead.

A handler that throws is caught and reported to `onError`. By the time it runs, `send` has already succeeded and the dialog is already showing its confirmation, so the throw can't turn a sent email back into a failure.

## In the editor

<img src="/images/test-email-modal.png" alt="The Send Test Email dialog — a recipient picker above a chrome-free preview of the template with a Desktop / Mobile switch, and Cancel / Send at the bottom" style="max-width: 480px;" />

1. A **Test** button in the editor header.
2. A dialog with the recipient control described above.
3. A live preview of the template (see below).
4. Send → a spinner, then a brief confirmation, then the dialog closes itself.
5. On failure, the dialog stays open with your error message inline.

## The preview

The dialog shows the template chrome-free at email width, with a desktop / mobile switch, so a user confirms what they're sending without leaving the dialog.

Two things it gets right that a naive preview would not:

- **Display conditions are honoured.** A block excluded by a condition is omitted, so the preview never shows content the recipient won't receive.
- **Responsive blocks follow the switch.** Templates with device-specific blocks render the variant a recipient on that device would get, rather than always the desktop one.

What it shows for merge tags depends on how much you configured — labels by default, `MergeTag.sample` values if you set them, or **data resolved by your own backend** if you wire `resolvePreview`, in which case it resolves for the *selected recipient*. See [Preview Rendering](/guide/preview-rendering); the dialog states which of the three is in effect beneath the switch.

Even fully resolved it is not a byte-for-byte preview of the delivered email: the real message is compiled HTML rendered by a mail client. Treat it as "is this the right template, with the right data?", not "is this exactly what lands in the inbox?".

The preview rides the dialog's own lazily-loaded chunk, so a consumer who never configures `testEmail` downloads none of it.

---

# Version History

Source: https://docs.templatical.com/backend/version-history

Give the editor a place to read versions from and it grows a history control in the header: step back through past states, preview one on the canvas, and restore it.

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  templates: myTemplatesProvider,
  versionHistory: {
    list: async (templateId) => {
      const res = await fetch(`/api/templates/${templateId}/versions`);
      return { versions: await res.json() };
    },

    get: async (templateId, versionId) => {
      const res = await fetch(
        `/api/templates/${templateId}/versions/${versionId}`,
      );
      const version = await res.json();
      return version.content;
    },

    create: false,

    restore: async (templateId, versionId) => {
      const res = await fetch(
        `/api/templates/${templateId}/versions/${versionId}/restore`,
        { method: 'POST' },
      );
      return res.json();
    },
  },
});
```

**Omitted by default.** With no provider the control does not render and none of its UI is downloaded.

Versions are scoped to a template id, so the control appears only once `create()` or `load()` has attached one. See [Saving & Loading](/backend/templates).

## The contract

```ts
interface TemplateVersion {
  id: string;
  createdAt: string;
  isAutomatic?: boolean;
  label?: string;
  author?: { id?: string; name?: string };
  content?: TemplateContent;
}

interface VersionHistoryProvider {
  list(templateId: string, params?: VersionHistoryListParams): Promise<VersionHistoryListResult>;
  get(templateId: string, versionId: string): Promise<TemplateContent>;
  create:  false | ((templateId: string, content: TemplateContent, meta?: { label?: string }) => Promise<TemplateVersion>);
  restore: false | ((templateId: string, versionId: string) => Promise<Template>);
}
```

`list` and `get` cannot be turned off — the editor must always be able to show a version and fetch its content. `create` and `restore` each take `false` in place of a function: `false` states that the action is unavailable, and the editor hides it rather than disabling it. `restore: false` leaves the history browsable with no Restore button; `create: false` means only your `save` records versions.

The editor renders `list()`'s order verbatim and never re-sorts. Ordering is your store's call.

::: warning Not a security boundary
These flags live in the user's browser. They shape the UI; they do not protect your API. Restoring overwrites the current template, so enforce server-side who may call it — and who may read a version's content, which is a past state of a document they may no longer have access to.
:::

## Recording versions

**The editor never records a version by itself.** Whichever `TemplatesProvider.save` you supply decides whether a save also records one:

```ts
const templates = {
  load: (id) => db.templates.get(id),
  create: (input) => db.templates.insert(input),
  save: async (id, patch) => {
    const template = await db.templates.update(id, patch);
    // Your call, your storage, your throttle.
    if (patch.content) await db.versions.insert({ id, content: patch.content });
    return template;
  },
};
```

`create` exists for versions a *person* asks for — a named checkpoint before a risky edit. The editor never calls it on its own. The rule is literal: nothing the editor does, including restoring, records a version behind your back.

::: tip Why the editor stays out of it
Throttling, retention and dedupe are decisions only the side paying for the storage can make. An editor that recorded a version per autosave tick would turn history into a keystroke log on someone else's disk.
:::

The one hazard this leaves — a restore discarding unsaved work — the editor handles by asking rather than by writing. See [Restoring](#restoring).

## Restoring

Restore is **append-only**: it adds an entry rather than rewriting one. Undo stays coherent, and two different backends cannot end up disagreeing about what history looks like afterwards.

A store with no atomic restore endpoint composes one in a line:

```ts
restore: async (templateId, versionId) => {
  const content = await versions.get(templateId, versionId);
  return templates.save(templateId, { content });
},
```

Two round-trips and a slightly wider failure window — and, because your `save` records a version, append-only for free.

With `restore: false` the history stays browsable and previewable, and the Restore button does not render.

### Unsaved changes

Cancelling a preview puts your work back; confirming a restore does not. The backup is discarded, so anything unsaved would exist nowhere afterwards.

Restore therefore asks first when there are unsaved changes, and offers to **save them before restoring**. The work then enters history the ordinary way, through your `templates.save` — user-initiated, and only if your `save` records versions at all.

Without a `templates` provider, or with one whose `save` is `false`, the offer isn't made: the confirmation says the changes will be lost, because there is nowhere to put them. With no unsaved changes, Restore goes through immediately.

## The `content` hint

Once a preview is open, stepping to another version swaps the canvas immediately — provided the content is already in hand. That is what the optional `content` field on each entry is for:

```ts
list: async (templateId) => {
  const rows = await db.versions.forTemplate(templateId);
  return rows.map((row, index) => ({
    id: row.id,
    createdAt: row.createdAt,
    isAutomatic: row.automatic,
    // Hydrate the recent ones; let the rest cost a round-trip.
    ...(index < 20 ? { content: row.content } : {}),
  }));
},
```

It is a **cache hint, never an alternative to `get`**, and it is evaluated per entry — hydrating recent versions and omitting older ones is a supported middle ground. When it is absent the editor calls `get` once for that version and caches the result, so only the first visit waits.

`get` stays required: the editor must always be able to obtain a version's content, whether or not the hint is there.

## In the editor

- **The history control** sits in the left header column with the template name and write time: arrows to step older and newer, and a dropdown listing every version with a relative timestamp, its label if it has one, and an *auto* badge for save-recorded ones.
- **The preview banner** appears while a past version is on the canvas, with Cancel and Restore.
- **Cancel** puts back exactly what you were editing, including unsaved changes. Autosave is paused for the duration of a preview, so a previewed version is never mistaken for your work and saved over it.
- **Restore** confirms first when there are unsaved changes — see [Unsaved changes](#unsaved-changes).

## Pagination

`list` takes `{ limit?, cursor? }` and resolves to an envelope:

```ts
type VersionHistoryListResult = { versions: TemplateVersion[]; nextCursor?: string };
```

The editor loads one page and calls `list` bare — it never sends `limit` or `cursor`, and it ignores `nextCursor`. A store that returns its whole history at once omits `nextCursor` and is finished.

The envelope exists so that adding pagination later is not a breaking change: a cursor has somewhere to live from day one. Reserving only the params object would have covered the request and left the response needing a new shape. `useVersionHistory` exposes `nextCursor` for headless callers that do page — see [Headless use](#headless-use).

## Events

```ts
versionHistory: {
  list, get, create, restore,
  onCreated:   (version) => {},
  onRestored:  (template) => {},
}
```

`onCreated` fires once `create()` resolves, with the recorded `TemplateVersion`. `onRestored` fires once `restore()` resolves, with the resulting `Template` rather than the `TemplateVersion` that was restored from — the caller already has that version's id, since it's what `restore(templateId, versionId)` was given.

A handler that throws is caught and reported to `onError` — it never fails the create or restore that triggered it.

## Headless use

`useVersionHistory` from `@templatical/core` is the reactive state on its own, without the editor:

```ts
import { useVersionHistory } from '@templatical/core';

const history = useVersionHistory({
  provider: myVersionHistoryProvider,
  getTemplateId: () => currentTemplateId,
  onError: (error) => console.error(error),
});

await history.load();
history.versions.value;                      // TemplateVersion[] — this page
history.nextCursor.value;                    // string | undefined — pass back as { cursor }
history.peekContent(v);                      // TemplateContent | null — no round-trip
await history.resolveContent(v);             // the hint, or get(), cached
await history.restore(v.id);
```

---

# Author features

Source: https://docs.templatical.com/getting-started/author-features

What authors see. Each row is off until you pass its key (or, for Issues, install the optional quality package). The editor with only `container` still edits; it does not persist, comment, or send.

Provider methods run in the browser. Hide a control with `false`; enforce the same rule on your server. See [Connect your backend](/backend/).

## Storage and send

| Feature | What authors do | `init()` key | Docs |
|---|---|---|---|
| Save and load | Name the template, save, autosave, unsaved-changes guard | `templates` | [Saving and loading](/backend/templates) |
| Version history | Step through past versions, preview, restore | `versionHistory` | [Version history](/backend/version-history) |
| Comments | Thread on a block, reply, resolve | `comments` | [Comments](/backend/comments) |
| Saved blocks | Bookmark a group, browse, insert | `savedBlocks` | [Saved blocks](/backend/saved-blocks) |
| Media library | Browse, upload, crop, folders | `media` | [Media](/backend/media) |
| Test email | Send this template to an inbox | `testEmail` | [Test emails](/backend/test-email) |
| MJML / HTML export | `toMjml()` / `toHtml()` on the instance | `render` (optional; local renderer also works) | [Rendering](/backend/render) |

A bundled browser-local adapter exists for saved blocks and media if you want the UI with no server of your own yet.

## Built-in chrome

No provider. Configure on `init()` or in template settings.

| Feature | What authors do | Docs |
|---|---|---|
| Merge tags | Insert CRM fields with a readable label | [Merge tags](/guide/merge-tags) |
| Display conditions | Show or hide a block per recipient | [Display conditions](/guide/display-conditions) |
| Issues | Jump to a lint finding, apply a fix | [Quality](/quality/) — install `@templatical/quality` |
| Custom blocks | Drop a type you registered | [Custom blocks](/guide/custom-blocks) |

Issues is an optional peer: skip the package and the tab never downloads.

---

# Embedding the editor

Source: https://docs.templatical.com/getting-started/embedding

The editor is a component you mount into an element of your own page. Almost everything that goes wrong at that seam is a CSS interaction between your page and the editor's overlays, and a small set of properties causes all of it.

Nothing here is Templatical-specific — these are plain CSS rules that affect any library positioning overlays with `position: fixed`. Symptom index: [Troubleshooting](/getting-started/troubleshooting). See also [Shadow DOM](../guide/shadow-dom) for how the editor isolates its own styles, and [Theming](../guide/theming) for the `--tpl-user-*` surface.

## The editor's container

Give the container a **definite height** — `100%` of a sized parent, or a `px` / `vh` value. The editor fills that box. Without a height it uses a small anti-collapse floor and the sidebar / footer clip. Symptom table: [Troubleshooting](/getting-started/troubleshooting).

The editor mounts its dialogs into a popover root at `z-index: 10000`, inside the container you pass to `init()`. A z-index only competes within its own stacking context, so **the container must not establish one** — otherwise every editor dialog is confined to it, and any chrome of yours with a higher z-index in the parent context paints over them.

These properties on the container, or on any ancestor between it and the stacking context your chrome lives in, create one:

| Property | Creating value |
| -------- | -------------- |
| `isolation` | `isolate` |
| `transform` / `translate` / `rotate` / `scale` | anything but `none` |
| `filter` / `backdrop-filter` | anything but `none` |
| `perspective` | anything but `none` |
| `opacity` | less than `1` |
| `will-change` | `transform`, `filter`, `opacity`, `perspective` |
| `contain` | `paint`, `layout`, `content`, `strict` |
| `mix-blend-mode` | anything but `normal` |
| `position: fixed` / `sticky` | always |
| `position: relative` / `absolute` | with any `z-index` other than `auto` |

If one of them is unavoidable — a route transition that animates `transform`, a wrapper you do not control — give that element a `z-index` higher than your own header, sidebar, or toast layer:

```css
#your-editor-container {
  /* Above your own chrome, so the editor's dialogs are too. */
  z-index: 200;
  position: relative;
}
```

::: tip Why a higher z-index on the dialog will not help
A `fixed` descendant cannot escape a stacking context at any z-index — the comparison happens between the context's root and your chrome, and the descendant's own value never enters it. So the value has to go on the container, not on the dialog. Raising the container is safe because its own box does not overlap your chrome; only the dialogs, which are `fixed` and cover the viewport, are affected.
:::

### The same properties also move things

`transform`, `filter`, `perspective`, `will-change` and `contain` do two jobs at once: alongside the stacking context above, each establishes a **containing block for `position: fixed`**. A fixed descendant then resolves its coordinates against that ancestor instead of the viewport — and this applies even while the computed `transform` reads `none`, because a running or animated transform still promotes the element.

What that means for the editor:

| Overlay | Under a transformed ancestor |
| ------- | ---------------------------- |
| Color pickers, rich-text toolbars, merge-tag autocomplete | Unaffected. They anchor `absolute` inside the popover root and convert viewport coordinates to root-local ones, so the ancestor's offset cancels out. |
| Dialog height | Unaffected. Every dialog caps against its own backdrop rather than the viewport, so it stays inside whatever box it is given. |
| Drag-and-drop ghost | **Offset.** The ghost is `position: fixed` and placed from viewport coordinates, so it drifts away from the cursor by the ancestor's offset. |

So if you use drag-and-drop, keep `transform` off every ancestor of the container.

::: warning Do not substitute `opacity`
Animating `opacity` instead of `transform` avoids the containing-block problem and walks straight into the stacking one — `opacity` below `1` creates a stacking context, so your chrome starts painting over the editor's dialogs. There is no property that dodges both. For a scroll or entrance effect, put it on an element that does not wrap the editor's container.
:::

### Clipping ancestors in Safari

`overflow: hidden`, `clip`, `auto` or `scroll` on an ancestor of the container creates neither a stacking context nor a containing block, so it is absent from the table above — and in Safari it still clips the dialogs.

Safari paints a `position: fixed` descendant clipped to such an ancestor's box while resolving its layout against the viewport. A dialog is placed correctly and then cut off at that ancestor's edge, and its dimming backdrop covers only that ancestor's area. Chrome and Firefox paint it across the viewport.

The editor keeps its own clip off the dialogs' ancestor chain, so its container may sit in a scrollable or clipped layout. An ancestor **of** the container is outside what the editor can reach:

```css
.your-app-shell {
  /* Clips the editor's dialogs in Safari. */
  overflow: hidden;
}
```

If the element only needs to contain its own children, `overflow: clip` with `overflow-clip-margin` will not help — every value clips. Move the property to an element that does not wrap the container, or let the page scroll normally.

## Host typography cannot leak in

The editor neutralizes every inheritable typography property at its root, so your page's global type styles do not reach its chrome or its canvas:

`letter-spacing` · `word-spacing` · `text-transform` · `font-style` · `font-weight` · `text-indent` · `text-align` · `white-space` · `list-style-type` · `cursor` · `font-variant-numeric` · `text-shadow` — alongside `font-family`, `font-size`, `line-height` and `color`.

This matters most for the canvas. A page-wide `text-transform: uppercase` reaching the preview would show you an email the recipient never receives, so the guarantee covers email content, not just editor chrome.

::: tip Why shadow DOM alone is not enough
Shadow DOM blocks host *rules* — a selector in your stylesheet never matches inside the editor's shadow root. It does not block *inheritance*, which follows the flattened tree, so inheritable properties cross the boundary regardless. The editor's own reset is what stops them, and it works identically with `shadowDom: false`.
:::

**`direction` is allowed to inherit onto the editor chrome.** An RTL page propagates its writing direction into the header and rails, which is what an RTL embedder wants. The email canvas sets its own `dir` from `settings.direction` (falling back to the content language), so the host does not decide how the email reads. `visibility` is likewise left alone.

### You do not need a CSS reset on the container

Resetting the container is not necessary, and an aggressive reset is harmful: `all: initial` or `all: revert` there wipes the `--tpl-user-*` custom properties that [Theming](../guide/theming) relies on, and can break the `height: 100%` chain the editor sizes against. Style the container's box — size, position, border — and leave its inherited values alone.

---

# Hosted builders

Source: https://docs.templatical.com/getting-started/hosted-builders

Templatical is an SDK you mount in your product. Storage, send, and accounts stay yours. Unlayer, BeeFree, Stripo, Topol, Chamaileon, and Easy Email Pro are hosted products: their dashboard, their storage, their terms.

Commercial embedding is free. You may not repackage Templatical as a competing email-editor product. [License FAQ](/license-faq).

## Comparison

| | Templatical | Hosted builder |
|---|---|---|
| Where authors work | Your app | Their site or iframe |
| Template storage | Your backend (`templates` on `init()`) | Theirs |
| Send | Your ESP or API | Often theirs |
| Output | JSON you keep; MJML via `@templatical/renderer`; HTML via any MJML library | Their HTML / their JSON |
| License | FSL-1.1-MIT on the editor; MIT on renderer, types, quality, importers | Vendor ToS |

## Importers

Each converter maps a native export onto Templatical JSON. Unmapped pieces become an `html` block or are skipped. The report on each runbook is the record of that.

| Source | Runbook |
|---|---|
| Unlayer | [From Unlayer](/guide/migration-from-unlayer) |
| BeeFree | [From BeeFree](/guide/migration-from-beefree) |
| Stripo | [From Stripo](/guide/migration-from-stripo) |
| Topol | [From Topol](/guide/migration-from-topol) |
| Chamaileon | [From Chamaileon](/guide/migration-from-chamaileon) |
| Easy Email Pro | [From Easy Email Pro](/guide/migration-from-easy-email-pro) |
| MJML | [From MJML](/guide/migration-from-mjml) |
| HTML | [From HTML](/guide/migration-from-html) |

MJML and HTML are the catch-alls when the native format is not in that list. The Agent Skill can run an import and then refine in the live editor — [Agent Skill](/guide/agent-skill).

---

# How Rendering Works

Source: https://docs.templatical.com/getting-started/how-rendering-works

Templatical separates template editing from template rendering. The editor produces JSON; the renderer turns that JSON into MJML. You then compile the MJML to HTML using any MJML library and send it via your email service.

## The pipeline

<svg viewBox="0 0 400 520" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:400px;margin:1.5em auto;display:block;">
  <defs>
    <marker id="ah" markerWidth="8" markerHeight="6" refX="4" refY="3" orient="auto">
      <path d="M0,0 L8,3 L0,6" fill="#94a3b8"/>
    </marker>
  </defs>
  <!-- Editor -->
  <rect x="60" y="10" width="280" height="52" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" rx="10"/>
  <text x="200" y="34" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="600" fill="#1e293b" text-anchor="middle">Editor</text>
  <text x="200" y="52" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#64748b" text-anchor="middle">drag &amp; drop</text>
  <!-- Arrow -->
  <line x1="200" y1="62" x2="200" y2="92" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ah)"/>
  <text x="260" y="82" font-family="ui-monospace, monospace" font-size="11" fill="#94a3b8" text-anchor="start">getContent()</text>
  <!-- JSON -->
  <rect x="60" y="96" width="280" height="52" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" rx="10"/>
  <text x="200" y="120" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="600" fill="#1e293b" text-anchor="middle">JSON</text>
  <text x="200" y="138" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#64748b" text-anchor="middle">TemplateContent — store in your database</text>
  <!-- Arrow -->
  <line x1="200" y1="148" x2="200" y2="178" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ah)"/>
  <text x="260" y="168" font-family="ui-monospace, monospace" font-size="11" fill="#94a3b8" text-anchor="start">renderToMjml()</text>
  <!-- MJML (highlighted) -->
  <rect x="60" y="182" width="280" height="52" fill="#fef3c7" stroke="#f59e0b" stroke-width="1.5" rx="10"/>
  <text x="200" y="206" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="600" fill="#1e293b" text-anchor="middle">MJML</text>
  <text x="200" y="224" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#92400e" text-anchor="middle">rendered by Templatical</text>
  <!-- Divider -->
  <line x1="30" y1="260" x2="370" y2="260" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="6 4"/>
  <text x="200" y="280" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#94a3b8" text-anchor="middle">Your code below — any MJML library, any language</text>
  <!-- Arrow -->
  <line x1="200" y1="290" x2="200" y2="310" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ah)"/>
  <!-- HTML -->
  <rect x="60" y="314" width="280" height="52" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" rx="10"/>
  <text x="200" y="338" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="600" fill="#1e293b" text-anchor="middle">HTML</text>
  <text x="200" y="356" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#64748b" text-anchor="middle">email-ready output</text>
  <!-- Arrow -->
  <line x1="200" y1="366" x2="200" y2="396" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ah)"/>
  <!-- Send -->
  <rect x="60" y="400" width="280" height="52" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" rx="10"/>
  <text x="200" y="424" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="600" fill="#1e293b" text-anchor="middle">Send</text>
  <text x="200" y="442" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#64748b" text-anchor="middle">your email service</text>
</svg>

1. **JSON** -- The editor's native format. A `TemplateContent` object with a `blocks` array and `settings` object. Save this to let users resume editing later.

2. **MJML** -- An intermediate markup language designed for email. Each Templatical block maps to an MJML component (`<mj-text>`, `<mj-image>`, `<mj-button>`, etc.). MJML handles the hard parts of email HTML: responsive tables, Outlook conditionals, and cross-client compatibility. **This is what Templatical's renderer produces.**

3. **HTML** -- The final output. MJML compiles to a complete HTML document with inline styles, nested tables, and client-specific workarounds. **You compile this step yourself** using any MJML library:

    | Language | Library |
    |----------|---------|
    | Node.js | [mjml](https://www.npmjs.com/package/mjml) (official) |
    | PHP | [spatie/mjml-php](https://github.com/spatie/mjml-php) |
    | Python | [mrml-python](https://github.com/jdrouet/mrml/tree/main/packages/mrml-python) |
    | Ruby | [mrml-ruby](https://github.com/hardpixel/mrml-ruby) |
    | Rust | [mrml](https://github.com/jdrouet/mrml) |
    | .NET | [Mjml.Net](https://github.com/SebastianStehle/mjml-net) |
    | Elixir | [mjml_nif](https://github.com/adoptoposs/mjml_nif) |

    See the full list on [mjml.io/community](https://mjml.io/community).

## MJML

[MJML](https://mjml.io) is an open-source markup language designed specifically for email. Email HTML is notoriously difficult. Every email client renders HTML differently -- Outlook uses Microsoft Word's rendering engine, Gmail strips `<style>` tags, Apple Mail supports modern CSS but Yahoo doesn't. Writing HTML that works everywhere requires:

- Nested tables for layout (flexbox and grid don't work)
- Inline styles on every element (external/embedded stylesheets are stripped)
- Outlook-specific conditional comments (`<!--[if mso]>`)
- Responsive breakpoints via `<style>` tags for clients that support them

MJML abstracts all of this. You write semantic components (`<mj-section>`, `<mj-column>`, `<mj-text>`), and MJML compiles them to compatible HTML.

By producing MJML instead of HTML directly, Templatical stays lightweight and gives you full control over the final output. You can use any MJML compiler in any language, and you can modify the MJML before compiling — injecting custom components, adding tracking pixels, or transforming the markup to fit your sending platform's requirements.

## What to store

**JSON — always.** `TemplateContent` is the source of truth: the only thing the editor can re-open, and the only input the renderer needs.

**MJML — never required.** It is an output, re-derivable from the JSON whenever you need it. Store it only as a cache, and only where you would otherwise pay to produce it twice:

<!-- prettier-ignore -->
| Where you render | Where `@templatical/renderer` is installed | Store the MJML? |
| --- | --- | --- |
| **In the browser** — no `render` provider, or `compileMjml` only | your **frontend** app, next to the editor | **Optional.** `toMjml()` is already at hand when the user saves, so keeping the result avoids re-rendering later. The [Quick Start](/getting-started/quick-start) does this. |
| **On your backend** — you implement `render.toMjml` | your **backend** — or nothing at all, if it renders in another language | **No.** Your backend produces the MJML as it renders. A browser copy is a second source that can disagree with it. See [Rendering & Export](/backend/render). |
| **Hosted Cloud** | nowhere — the hosted backend renders from the *saved* template and re-derives on demand | **No.** |

::: warning A stored render is a cache
MJML depends on the JSON *and* on your renderer — your fonts config, any `blockRenderers` overrides, the package version itself. Change any of those and stored copies are stale while the JSON still renders correctly. Re-derive rather than repair.
:::

## What the renderer does

`@templatical/renderer` takes a `TemplateContent` JSON object and produces a complete MJML document. Specifically, it:

- Converts each block in the JSON tree to its corresponding MJML component (text → `<mj-text>`, image → `<mj-image>`, button → `<mj-button>`, etc.)
- Applies block styles (padding, background color) as MJML attributes
- Spaces rich-text `<p>`, `<ul>`, `<ol>` and `<li>` to match the editor canvas, overriding MJML's own `p { margin: 13px 0 }` default
- Injects custom font declarations as `<mj-font>` tags
- Preserves merge tags unchanged (they pass through as literal text)
- Respects `visibility` settings -- blocks hidden on all viewports are omitted
- Wraps blocks with `displayCondition` before/after strings
- Optionally strips raw HTML blocks when `allowHtmlBlocks` is `false`

## Renderer limits

- **Compile MJML to HTML** -- Use any [MJML library](#the-pipeline) for this step.
- **Evaluate merge tags** -- Tags like <code v-pre>{{ first_name }}</code> pass through as-is and are replaced at send time by your email platform.
- **Evaluate display conditions** -- Conditional wrapping (e.g., `{% if %}`) passes through for your sending platform to process.
- **Send emails** -- The renderer produces MJML. Sending is handled by your email service.
- **Optimize images** -- Images are referenced by URL. The renderer doesn't download, resize, or optimize them.

## Next steps

- [Renderer API](/api/renderer-typescript) -- full `renderToMjml()` reference

---

# Installation

Source: https://docs.templatical.com/getting-started/installation

::: tip Active development
Templatical is under active development and ships frequently. The public API is stabilizing — we follow [SemVer](https://semver.org), use [changesets](https://github.com/changesets/changesets) for every release, and document breaking changes in the [changelog](https://github.com/templatical/sdk/releases). Pin a version in production and watch [GitHub releases](https://github.com/templatical/sdk/releases) to stay current.

Have a feature request or hit a rough edge? [Open a discussion](https://github.com/templatical/sdk/discussions) — feedback shapes the roadmap.
:::

## Requirements

- **Modern browser** -- support depends on which mount mode you use:
  - **Default mode** (`shadowDom: true`, Shadow DOM) — Chrome 80+, Edge 80+, Firefox 101+, Safari 16.4+. Firefox and Safari minimums are driven by the `adoptedStyleSheets` API the shadow path relies on.
  - **Opt-out mode** (`shadowDom: false`, light DOM) — Chrome 80+, Edge 80+, Firefox 80+, Safari 14+. Use this if you need to support older Firefox or Safari, or if your integration requires light-DOM access to editor internals. See the [Shadow DOM guide](../guide/shadow-dom) for trade-offs.
- **Container element** -- must have a defined height (the editor fills its container). In default mode, must be an element type that can host a shadow root (e.g. `<div>`, `<section>`, `<article>`). See [container element requirements](../api/editor#container-element-requirements).
- **No `transform`, and no stacking context, on an ancestor of the container** -- `transform`, `filter`, `perspective`, `will-change`, `opacity` below `1`, `isolation`, `contain`, and positioned elements with a `z-index` each change where the editor's overlays are painted or positioned. These are plain CSS rules, not Templatical-specific limitations, and they affect any library that positions overlays with `position: fixed`. See [Embedding the editor](./embedding) for what each one breaks and how to work around it.
- **No required peer dependencies** -- Vue, TipTap, and all internal libraries are bundled into the editor. You don't need to install Vue or any framework runtime, regardless of which framework your app uses. (`@templatical/renderer`, `@templatical/quality`, and `pusher-js` are _optional_ peers — install them only if you use the corresponding feature; see [Optional peers](#optional-peers) below.)

## Network requests

The editor makes **no** requests to Templatical. There is no license key, no client ID, no activation call, no entitlement check, and no telemetry. Nothing about the editor is enabled or disabled remotely, so an installed copy keeps working indefinitely.

It does make exactly one third-party request, and you should know about it before you deploy:

| Request | Made by | When |
| ------- | ------- | ---- |
| `https://fonts.bunny.net/css?family=geist:400,500,600` | A CSS `@import` at the top of the editor stylesheet | Whenever the stylesheet is parsed, in both DOM modes |

Geist is the editor's default UI font. If the request to load it is blocked or fails, the editor works normally — text falls back to the next family in the stack. Two cases where you might notice:

- **Strict Content Security Policy** — a policy such as `style-src 'self'` blocks the `@import`. Add `https://fonts.bunny.net` to `style-src` and `font-src`, or accept the fallback font.
- **Air-gapped or offline deployments** — the request fails and the fallback font is used.

To stop the editor depending on Geist, override the font token:

```css
.tpl,
#your-editor-container {
  --tpl-user-font-family: system-ui, sans-serif;
}
```

The `@import` is still present in the stylesheet, so the request is still attempted. To remove it outright, self-host Geist and strip the `@import` from your copy of `dist/style.css` as a build step. See [Theming](../guide/theming) for the full font token surface.

## The editor's container

The container you pass to `init()` has a few CSS constraints, and an ancestor with `transform`, `overflow: hidden`, or its own stacking context can misplace or clip the editor's dialogs.

See [Embedding the editor](./embedding) for what each property breaks and how to work around it.

## npm

::: code-group

```bash [npm]
npm install @templatical/editor
```

```bash [pnpm]
pnpm add @templatical/editor
```

```bash [yarn]
yarn add @templatical/editor
```

```bash [bun]
bun add @templatical/editor
```

:::

`@templatical/editor` is the visual editor. To convert templates to MJML, also install `@templatical/renderer`:

::: code-group

```bash [npm]
npm install @templatical/renderer
```

```bash [pnpm]
pnpm add @templatical/renderer
```

```bash [yarn]
yarn add @templatical/renderer
```

```bash [bun]
bun add @templatical/renderer
```

:::

The renderer is **optional**. Install it where you need MJML output:

- **Browser, with the editor** — when calling `editor.toMjml()` to export from the user's session.
- **Node.js / server** — when you only have stored template JSON and want to convert it to MJML server-side. You don't need the editor for this; install just the renderer.

If you call `editor.toMjml()` without the renderer installed, it throws a clear error naming the missing package.

## Package overview

| Package                        | Description                                                                                                                              | When to install                                                                                     |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `@templatical/editor`          | Visual drag-and-drop editor and `init()` entry point. Self-contained — Vue, TipTap, and `@templatical/core`/`/types` are bundled inside. | Required                                                                                            |
| `@templatical/renderer`        | Converts templates to MJML for email sending.                                                                                            | Optional — install where you call `editor.toMjml()` (browser) or `renderToMjml()` (Node.js, server) |
| `@templatical/quality`         | Template linters (accessibility, structure, links) that drive the editor's Issues panel and a headless / CI check.                             | Optional — install to turn on the Issues sidebar tab and inline block badges                        |
| `@templatical/media-library`   | Standalone media SDK (`init()`, `useMediaLibrary`, `MediaLibraryModal` in a Vue app). The editor's Browse UI is bundled into `@templatical/editor`. | Optional — only for standalone use, not for `init({ media })` or Cloud's store |
| `@templatical/types`           | Shared TypeScript types, block factory functions, type guards.                                                                           | Only if you build templates programmatically without the editor (e.g. server-side workflows)        |
| `@templatical/core`            | Framework-agnostic editor logic (state, history) for headless setups.                                                                    | Only for headless / non-editor consumers                                                            |
| `@templatical/template-tools`  | CLI and library for validating, rendering, editing, importing, and live-previewing templates outside the editor.                        | Optional — run via `npx`, nothing to install; for CI checks, scripts, or generating templates without a mounted editor |
| `@templatical/import-beefree`  | Converts BeeFree JSON templates to Templatical format.                                                                                   | Optional                                                                                            |
| `@templatical/import-unlayer`  | Converts Unlayer JSON design templates to Templatical format.                                                                            | Optional                                                                                            |
| `@templatical/import-html`     | Converts existing HTML email templates (table-based) to Templatical format.                                                              | Optional                                                                                            |
| `@templatical/import-mjml`     | Converts MJML email templates to Templatical format.                                                                                     | Optional                                                                                            |
| `@templatical/import-topol`    | Converts Topol JSON templates to Templatical format.                                                                                     | Optional                                                                                            |
| `@templatical/import-stripo`   | Converts Stripo plugin HTML and compiled File→HTML exports to Templatical format.                                                        | Optional                                                                                            |
| `@templatical/import-chamaileon` | Converts Chamaileon `getDocument()` JSON to Templatical format.                                                                        | Optional                                                                                            |
| `@templatical/import-easy-email-pro` | Converts Easy Email Pro persist JSON to Templatical format.                                                                        | Optional                                                                                            |

`@templatical/editor` ships as a single self-contained ESM bundle: every runtime dependency it needs (Vue, TipTap, vue-draggable-plus, `@templatical/core`, `@templatical/types`, etc.) is inlined. You never install them separately — and you never get duplicate copies in your app's `node_modules`.

## Optional peers

The editor lazy-loads three optional peers via dynamic `import()` at runtime, gated by feature use:

| Peer                         | When loaded                     | Install if you                    |
| ---------------------------- | ------------------------------- | --------------------------------- |
| `@templatical/renderer`      | First call to `editor.toMjml()` | Need MJML export from the browser |
| `@templatical/quality`       | Editor mount (Issues panel)     | Want accessibility, structure, and link lint in the Issues sidebar |
| `pusher-js`                  | Cloud realtime connect          | Use `initCloud()`                 |

If you don't install them, the editor still mounts. Quality's Issues tab and Pusher stay off. `editor.toMjml()` throws a clear error naming the missing package. Browse needs no extra package — the modal is a lazy chunk of the editor.

### A note on bundler output

The editor works out of the box with every modern bundler — no consumer configuration is required regardless of which optional peers you install. Vite, esbuild, Rollup, and Rolldown handle the optional dynamic imports silently. Webpack 5 is slightly more verbose: it statically analyzes every `import()` and prints a harmless `Module not found` **warning** for each uninstalled optional peer. The build still succeeds and the editor runs correctly — these warnings are cosmetic only.

If you'd prefer a clean Webpack log, you can opt into silencing them with `ignoreWarnings`:

```js
// webpack.config.js — optional, only if the warnings bother you
module.exports = {
  ignoreWarnings: [
    {
      module: /@templatical[\\/]editor/,
      message:
        /Can't resolve '(pusher-js|@templatical\/(quality|renderer))'/,
    },
  ],
};
```

## Framework integration

Templatical mounts into any DOM element. It creates its own isolated application internally, so it works with any framework — or no framework at all.

::: code-group

```ts [Vanilla JS]
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";

const editor = await init({
  container: "#editor",
  onChange(content) {
    console.log("Content changed", content);
  },
});

// Later, when removing the editor:
editor.unmount();
```

```tsx [React]
import { useRef, useEffect } from "react";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";

export function EmailEditor() {
  const containerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<TemplaticalEditor | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    let cancelled = false;
    let instance: TemplaticalEditor | null = null;

    (async () => {
      const ed = await init({
        container: containerRef.current,
        onChange(content) {
          console.log("Content changed", content);
        },
      });
      if (cancelled) {
        ed.unmount();
        return;
      }
      instance = ed;
      editorRef.current = ed;
    })();

    return () => {
      cancelled = true;
      instance?.unmount();
      editorRef.current = null;
    };
  }, []);

  return <div ref={containerRef} style={{ height: "100vh" }} />;
}
```

```vue [Vue]
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";

const container = ref<HTMLElement>();
let editor: TemplaticalEditor | null = null;

onMounted(async () => {
  if (!container.value) return;

  editor = await init({
    container: container.value,
    onChange(content) {
      console.log("Content changed", content);
    },
  });
});

onUnmounted(() => {
  editor?.unmount();
});
</script>

<template>
  <div ref="container" style="height: 100vh" />
</template>
```

```svelte [Svelte]
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { init } from '@templatical/editor';
  import '@templatical/editor/style.css';
  import type { TemplaticalEditor } from '@templatical/editor';

  let containerEl: HTMLElement;
  let editor: TemplaticalEditor | null = null;

  onMount(async () => {
    editor = await init({
      container: containerEl,
      onChange(content) {
        console.log('Content changed', content);
      },
    });
  });

  onDestroy(() => {
    editor?.unmount();
  });
</script>

<div bind:this={containerEl} style="height: 100vh;" />
```

```ts [Angular]
import {
  Component,
  ElementRef,
  OnDestroy,
  OnInit,
  ViewChild,
} from "@angular/core";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";

@Component({
  selector: "app-email-editor",
  standalone: true,
  template: `<div #editorContainer style="height: 100vh"></div>`,
})
export class EmailEditorComponent implements OnInit, OnDestroy {
  @ViewChild("editorContainer", { static: true })
  containerRef!: ElementRef<HTMLElement>;

  private editor: TemplaticalEditor | null = null;

  async ngOnInit(): Promise<void> {
    this.editor = await init({
      container: this.containerRef.nativeElement,
      onChange(content) {
        console.log("Content changed", content);
      },
    });
  }

  ngOnDestroy(): void {
    this.editor?.unmount();
  }
}
```

:::

If the effect re-runs before `init()` resolves — React StrictMode does this in development — unmount the instance that just finished, not only the ref.

::: warning Important
Always call `unmount()` when removing the editor from the page. This cleans up event listeners, timers, and DOM elements. This is especially important in single-page applications where components mount and unmount during navigation.
:::

## TypeScript support

All packages ship with full TypeScript type definitions. Configuration options, callback payloads, block types, and instance methods are fully typed:

```ts
import { init, unmount } from "@templatical/editor";
import type {
  TemplaticalEditor,
  TemplaticalEditorConfig,
} from "@templatical/editor";
import type {
  TemplateContent,
  Block,
  ThemeOverrides,
  FontsConfig,
} from "@templatical/types";
```

## Release tarballs

Every GitHub release carries the same tarballs that go to npm, one per package. Install from those when a build can't reach the registry, or when your dependencies have to come from URLs you vet yourself.

```json
{
  "dependencies": {
    "@templatical/renderer": "https://github.com/templatical/sdk/releases/download/v<version>/templatical-renderer-<version>.tgz"
  }
}
```

`<version>` is the package version and the tag is the same with a `v` in front — every published version has one on the [releases page](https://github.com/templatical/sdk/releases). The file is the one npm would have served you, so nothing about the package behaves differently.

Three things to know:

**Pin the Templatical packages you depend on indirectly, too.** A tarball refers to its siblings by version number, so your package manager still goes looking for that version on the registry. `@templatical/core`, `@templatical/quality`, `@templatical/renderer`, `@templatical/media-library` and the importers all depend on `@templatical/types`. Point each one you pull in at a tarball:

```yaml
# pnpm-workspace.yaml
overrides:
  '@templatical/types': https://github.com/templatical/sdk/releases/download/v<version>/templatical-types-<version>.tgz
```

npm and Yarn do the same thing with `overrides` and `resolutions` in `package.json`. `@templatical/editor` needs none of this — it bundles everything it uses.

**Third-party dependencies still come from a registry.** `@templatical/types`, `@templatical/renderer`, `@templatical/import-beefree`, `@templatical/import-unlayer`, `@templatical/import-topol`, `@templatical/import-chamaileon` and `@templatical/import-easy-email-pro` install with nothing else at runtime. The rest pull packages that aren't ours: `@templatical/core` needs `@vue/reactivity`, `@templatical/quality` needs `htmlparser2`, `@templatical/import-html`, `@templatical/import-mjml` and `@templatical/import-stripo` need `cheerio` and `domhandler`, and `@templatical/media-library` needs `@lucide/vue`, `@vueuse/core` and `vue-advanced-cropper`. Installing those without a registry needs a mirror for them too.

**The source archives on that page are not a substitute.** "Source code (zip)" and "Source code (tar.gz)" are snapshots of the repository, as is a `github:templatical/sdk` dependency. Neither contains a built `dist/`, and both refer to sibling packages as `workspace:*`, which resolves to nothing outside this repo.

## CDN

If you prefer not to use a package manager, load the editor directly via script tags:

```html
<link
  rel="stylesheet"
  href="https://unpkg.com/@templatical/editor/dist/cdn/editor.css"
/>
<script type="module">
  import { init } from "https://unpkg.com/@templatical/editor/dist/cdn/editor.js";

  const editor = await init({
    container: "#editor",
  });
</script>

<div id="editor" style="height: 100vh;"></div>
```

The CDN build is fully self-contained — all dependencies are bundled. Heavy libraries (TipTap, Vue, Pusher, etc.) are code-split into separate chunks and loaded on demand.

### Pinning a version

The example above is unversioned on purpose: unpkg resolves a URL with no version segment to the latest published release on every request, which is convenient for trying the editor but means the exact code your page loads can change with no corresponding change on your end. Pin an exact version for production, loading it from jsDelivr rather than unpkg:

```html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@templatical/editor@<version>/dist/cdn/editor.css"
/>
<script type="module">
  import { init } from "https://cdn.jsdelivr.net/npm/@templatical/editor@<version>/dist/cdn/editor.js";

  const editor = await init({
    container: "#editor",
  });
</script>
```

**The host changes along with the URL shape, not just the version number.** The CDN build code-splits into hashed chunk files that the entry loads on demand, so the entry and every chunk it pulls in must resolve to the same published version — unpkg's redirect from an unversioned URL keeps that consistent, but requesting an already-pinned version's chunks from unpkg has intermittently served them with the wrong content type and a failing CORS preflight. jsDelivr serves a pinned version's chunks reliably, so keep pinned URLs there and the unversioned form on unpkg.

---

# Starting points

Source: https://docs.templatical.com/getting-started/paths

Commercial use is free. The [License FAQ](/license-faq) is the competing-product restriction in plain language.

## Embed the editor

Mount `@templatical/editor` in your product. Any framework, or none.

- [Quick Start](/getting-started/quick-start) — mount, save JSON, compile MJML to HTML
- [Installation](/getting-started/installation) — packages, CDN, React / Vue / Svelte / Angular
- [Embedding](/getting-started/embedding) — container height, stacking, `position: fixed`

## Connect storage and send

Saving, versions, comments, saved blocks, media, test email, and HTML export are keys you pass to `init()`. Omit a key and that UI is absent.

- [Connect your backend](/backend/) — the contracts
- [Author features](/getting-started/author-features) — what people in the editor actually see

## Generate a template from a prompt

Your coding agent writes and validates JSON. No Templatical account.

- [Agent Skill](/guide/agent-skill)
- [Template Tools](/api/template-tools) — the same validate / render / import / live commands from a shell

## JSON to sendable HTML

The editor stores JSON. `@templatical/renderer` emits MJML. Any MJML library compiles HTML. You send through whatever you already use.

- [How Rendering Works](/getting-started/how-rendering-works)
- [Renderer API](/api/renderer-typescript)
- [Template Tools](/api/template-tools) `render --format html`

## Leave a hosted builder

Unlayer, BeeFree, Stripo, and the others are products with their own accounts. Importers map their exports onto Templatical JSON.

- [Hosted builders](/getting-started/hosted-builders)

---

# Quick Start

Source: https://docs.templatical.com/getting-started/quick-start

## 1. Install packages

::: code-group

```bash [npm]
npm install @templatical/editor @templatical/renderer
```

```bash [pnpm]
pnpm add @templatical/editor @templatical/renderer
```

```bash [yarn]
yarn add @templatical/editor @templatical/renderer
```

```bash [bun]
bun add @templatical/editor @templatical/renderer
```

:::

## 2. Mount the editor

Paste this into an HTML file and open it. No bundler. Framework and package-manager mounts live on [Installation](/getting-started/installation). Pin a version for production — [CDN](/getting-started/installation#cdn).

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Templatical Editor</title>
  <link
    rel="stylesheet"
    href="https://unpkg.com/@templatical/editor/dist/cdn/editor.css"
  />
  <style>
    body { margin: 0; }
    #editor { height: 100vh; }
  </style>
</head>
<body>
  <div id="editor"></div>
  <script type="module">
    import { init } from "https://unpkg.com/@templatical/editor/dist/cdn/editor.js";

    const editor = await init({
      container: "#editor",
    });

    const json = editor.getContent();
    const mjml = await editor.toMjml();
  </script>
</body>
</html>
```

Store `json` so users can edit later. Compile `mjml` on the server — `toMjml()` does not produce HTML.

## 3. Compile MJML to HTML

On the server, compile the MJML you just posted with any [MJML library](https://mjml.io) — Node, PHP, Python, Ruby, and others. [How Rendering Works](/getting-started/how-rendering-works) lists them. The Node package is `mjml`:

::: code-group

```bash [npm]
npm install mjml
```

```bash [pnpm]
pnpm add mjml
```

```bash [yarn]
yarn add mjml
```

```bash [bun]
bun add mjml
```

:::

```ts
import mjml2html from "mjml";

const { html } = mjml2html(mjml);
// html is ready to send
```

From a saved JSON file, without mounting the editor (also needs `mjml`):

::: code-group

```bash [npm]
npx -y @templatical/template-tools render template.json --format html -o email.html
```

```bash [pnpm]
pnpm dlx @templatical/template-tools render template.json --format html -o email.html
```

```bash [yarn]
yarn dlx @templatical/template-tools render template.json --format html -o email.html
```

```bash [bun]
bunx @templatical/template-tools render template.json --format html -o email.html
```

:::

See [Template Tools](/api/template-tools).

::: info Shadow DOM by default
The editor mounts inside a Shadow DOM, so host page CSS cannot cascade into editor elements. Use a `<div>` — or any [shadow-host-eligible element](/api/editor#container-element-requirements) — as the container; elements like `<table>`, `<form>`, or `<input>` cannot host a shadow root.

Pass `shadowDom: false` to opt out if you need an unusual container, target editor internals from `document.querySelector`, or support Firefox <101 / Safari <16.4. See the [Shadow DOM guide](/guide/shadow-dom) for the full trade-off list and theming via `:host`.
:::

## Next steps

- [Starting points](/getting-started/paths) — embed, backend, prompt, or JSON → HTML.
- [Embedding](/getting-started/embedding) — container rules, stacking, and what breaks host CSS isolation.
- [Connect your backend](/backend/) — save, versions, comments, saved blocks, media, test email, render.
- [Template Tools](/api/template-tools) — validate, render, import, and live-preview from a CLI or script.
- [How Rendering Works](/getting-started/how-rendering-works) — JSON → MJML → HTML, and what to store.

---

# Troubleshooting

Source: https://docs.templatical.com/getting-started/troubleshooting

Match the symptom to the row before changing anything. Several of these look like a broken build and throw nothing. Container and stacking detail: [Embedding](/getting-started/embedding).

| Symptom | Cause | What to do |
|---|---|---|
| Chrome renders, clicks / drags / keystrokes do nothing | A second Vue reactivity instance. `@templatical/core` (or any other Vue-using `@templatical/*` package) is in the app's own `dependencies`, so refs the editor creates are invisible to that second `WeakMap`. | Keep those packages out of the app's `dependencies`. The editor already bundles them. |
| Editor mounts, layout is gone | `@templatical/editor/style.css` was not imported. The `exports` map resolves that subpath to `dist/style.css`. | Import the stylesheet next to `init()`. Do not install `tailwindcss` as a peer — it is compiled into that file. |
| Dialogs clipped, painted under host chrome, or a drag ghost that drifts | An ancestor of the container is a containing block for `position: fixed`: `transform`, `filter`, `backdrop-filter`, `perspective`, `will-change`, `contain`, `isolation`, `opacity` below `1`, or a positioned element with `z-index`. | Remove that property from ancestors, or mount outside that stacking context. |
| Sidebar last items / footer clip, no scroll | The container has no definite height. The editor fills its parent; without one it uses a small anti-collapse floor and chrome still assumes real height. | Give the container a real height (`100%` of a sized parent, or a `px`/`vh` value). |
| React 18 StrictMode: extra editors, leaked listeners | `init()` is async. StrictMode unmounts the effect before the promise resolves, so the created instance is never stored and never `unmount()`ed. | Unmount the instance that just finished when the effect was cancelled. See [Installation](/getting-started/installation). |
| `toHtml()` throws / `toMjml()` asks for a package | The SDK ships no MJML compiler. `toMjml()` lazy-imports `@templatical/renderer`. HTML is any MJML library, `render.compileMjml`, or `template-tools render --format html`. | Install the renderer peer for MJML. Compile HTML on the server. [How Rendering Works](/getting-started/how-rendering-works). |
| `require('@templatical/editor')` fails | ESM only. No `main`, no CJS, no UMD. | Use `import`. A CJS-only app needs a bundler that can consume ESM. |
| Theming tokens vanish after `all: initial` on the container | Inheritance crosses the shadow boundary. Resetting the container wipes `--tpl-user-*`, which is the theming surface, and can break the height chain. | Do not reset the container. The editor already neutralizes host typography at its own root. |

The Agent Skill's `diagnose` mode walks the same table against an existing `init()` call.

---

# Editor accessibility

Source: https://docs.templatical.com/guide/accessibility

This page is the **editor UI** — rails, canvas chrome, dialogs, shortcuts. The email a recipient gets is a different surface: [`@templatical/quality`](/quality/) lints that JSON. Do not treat this page as a VPAT or a WCAG score for sent mail.

## Keyboard

Modifier is `metaKey || ctrlKey` (Command on macOS, Control elsewhere). The listener is on `document` so a click on a non-focusable block still receives the chord.

While a text field or TipTap surface is focused, undo/redo and Delete/Backspace stay with the field.

| Chord | Action |
|---|---|
| `Mod+S` | Save, when a templates provider can save |
| `Mod+Z` | Undo. TipTap handles this while editing text |
| `Mod+Shift+Z` | Redo. Same text-editing exception |
| `Escape` | Deselect the current block |
| `Delete` / `Backspace` | Remove the selected block. No-op while typing |

### Palette

Click, Enter, or Space on a sidebar palette item inserts that block **below the selection** (or at the end when nothing is selected). A nested column child cannot accept a section — MJML forbids `mj-section` in `mj-column` — so a section insert lands after the parent section. Same rule as `duplicateBlock`.

Drag-and-drop is pointer-emulated (`force-fallback`). There is no keyboard drag. Palette insert is the pointer-free add path.

### Saved-blocks pick session

While picking blocks to save:

| Chord | Action |
|---|---|
| `Escape` | Cancel the session |
| `Enter` | Confirm (disabled at zero picks) |
| `Delete` / `Backspace` | Swallowed — does not delete canvas blocks |

Arrow Up/Down on a focused reorder grip in the save dialog moves a preview row.

## Focus and shadow DOM

Default mount is an open shadow root on the container. `document.activeElement` and `window.getSelection()` stop at the shadow host, so chrome that needs the real focus target goes through the editor's own root (shadow root or `document` in light-DOM mode). Both expose the same `activeElement` / `getSelection` surface.

The keydown listener stays on `document`: a click on a non-focusable block leaves `activeElement` on `body`, and a shadow-root-bound listener would miss `Mod+Z` / `Mod+S`.

Opt out with `shadowDom: false`. Isolation, theming, and this focus split: [Shadow DOM](/guide/shadow-dom).

## Dialogs

Popups teleport to `.tpl-popover-root` inside the editor, never `document.body`. That root is a stacking context (`z-index: 10000`), so dialogs stay in the editor even when the host page has its own modals.

Height caps are a **percentage of the backdrop**, not `vh`. A host ancestor with `transform` / `filter` / `contain` becomes the containing block for `position: fixed`; a `vh` cap would overflow that box. Symptom table: [Troubleshooting](/getting-started/troubleshooting).

## Chrome vs canvas

Header, rails and dialogs follow `init({ locale })` and the UI theme (`data-tpl-theme`). The email canvas follows `settings.locale` and `settings.direction` — those become `<html lang>` / `<mjml dir>` on export. A German editor writing an English campaign keeps English countdown labels and `lang="en"` on the canvas.

Nested block chrome (action bar on a section child) uses `--tpl-chrome-*` tokens so a dark UI does not inherit the canvas's forced-light colors.

## Roles in the chrome

| Control | Pattern |
|---|---|
| Viewport toggle | `role="radiogroup"` / `role="radio"` + `aria-checked` |
| Merge-tag suggestion popup | combobox + listbox (`aria-activedescendant`) |
| Saved-blocks first load | `role="status"` + `aria-busy`; skeleton bars `aria-hidden` |
| Required custom-block field | visible `*` is `aria-hidden`; name is an `.tpl-sr-only` sibling |
| Native checkboxes | none in editor source — sliding `role="switch"` instead |

## Small screen

The supported floor is 768px. Below that, `SmallScreenNotice` sits above dialogs (later sibling of the popover root) and tells the author the chrome is not a phone UI.

## Host duties

These are the same constraints as [Embedding](/getting-started/embedding). They are load-bearing for AT as well as for sighted layout:

- Give the container a definite height.
- Do not put `transform`, `filter`, `backdrop-filter`, `contain`, or `isolation` on an ancestor of the container if you want dialogs to cover the viewport.
- Do not `all: initial` / `all: revert` the container — that wipes `--tpl-user-*` and can break the height chain.

## See also

- [`@templatical/quality`](/quality/) — accessibility, structure and link rules on the **template JSON**
- [Troubleshooting](/getting-started/troubleshooting)
- [Embedding](/getting-started/embedding)

---

# AI Agent Skill

Source: https://docs.templatical.com/guide/agent-skill

Templatical ships one [Agent Skill](https://agentskills.io) — **free, open-source, and running entirely in your own AI coding agent:** `templatical`. Ask it for an email and it writes, edits, imports, validates or previews a template; ask it about the SDK and it installs, mounts, scaffolds or troubleshoots an `@templatical/editor` integration. One skill, and it decides for itself which job a request needs.

There is no backend and no API key: your agent is the inference. Nothing is sent to Templatical.

## Install

```bash
npx skills add templatical/sdk
```

To update:

```bash
npx skills update templatical
```

If your agent doesn't pick the skill up afterwards, check that it is enabled in the agent's own skill list.

## Capabilities

- **Build** a template from a brief — "make a product-launch email", "design a welcome email".
- **Edit** an existing template as a scoped change, not a rewrite.
- **Import** a template from almost anywhere — dedicated converters for Unlayer, BeeFree, Stripo, Topol, Chamaileon, Easy Email Pro, MJML and HTML, and for anything else it maps the source onto the block schema by hand.
- **Validate** a template's JSON against the block schema and get back precise, per-block errors.
- **Export** a template as sendable MJML or HTML.
- **Preview it live** in the real Templatical editor in your browser, kept in sync as you keep prompting — your own hand-edits included.
- **Integrate** the editor into an application — detect the stack, propose the change, and wait before writing anything.
- **Scaffold** a proposed integration into your repository, installed with your own package manager and checked against your running dev server.
- **Diagnose** an integration that misbehaves, against a table of verified traps.
- **Answer a question** about the SDK by fetching this documentation site directly.

The mechanical steps — validate, render, import, live preview — are [`@templatical/template-tools`](/api/template-tools). The skill drives that CLI; you can run the same commands from a shell with no agent.

::: tip Prefer a hosted experience?
An in-editor AI chat, tuned prompts, and a hosted MCP server are a hosted-tier path. This skill is the open, self-hosted path — bring your own agent, keep full control.
:::

## Examples

### Build, validate, and export an email — no integration needed

Describe the email you want. For example:

> Give me a product-launch email for an outdoors brand — a hero image, a short intro, and a "Shop now" button, using forest green and a warm neutral background.

The agent will:

1. Read the block schema and worked examples bundled with the skill.
2. Generate a complete template as `{ blocks, settings }` JSON.
3. Run the validator itself and fix any structural or accessibility issues it reports — repeating until the template passes.
4. Hand you the finished email: export **MJML/HTML to send**, or load the JSON into your own editor integration with `editor.setContent(json)`.

That's a complete email, start to finish, with no integration to build. You can also preview it live before exporting — see the next example.

### Preview and hand-edit it live

Ask to **show it live** (or "preview it live", "build this in live mode") and the skill:

1. Opens a live preview in your browser, showing your current template in the real editor.
2. Updates it **live** each time you prompt a change — no refresh.
3. Lets you **hand-edit in the browser** too; the agent notices when you've diverged and asks whether to build on your version or replace it before overwriting.

Build in plain JSON first and switch to a live preview mid-session — it picks up right where you are. Live mode is local and single-user, and needs nothing beyond a coding agent running on your own machine.

### Import an existing template

Already have a template somewhere else? The skill converts **Unlayer**, **BeeFree**, **Stripo**, **Topol**, **Chamaileon**, **Easy Email Pro**, **MJML**, and **HTML** emails into Templatical JSON — point it at the file and it writes a working template plus a short report of what converted cleanly and what fell back to raw HTML (import is lossy by nature). Then preview it live and refine the rough edges into native blocks.

**No converter for your source? It still works.** The skill knows the block schema, so it can read an unfamiliar template and map it across by hand — a WordPress block template, a Mailchimp or Klaviyo export, a hand-written HTML email, even a screenshot or PDF of one. It reads the source, plans the mapping, builds the blocks, then validates the result like any other template. Slower and less exact than a converter, and worth asking for by name.

The converter list grows, so the skill asks the CLI what is available rather than assuming — you never need to check first.

To run the converters directly in your own code instead, see the migration guides: [Unlayer](/guide/migration-from-unlayer), [BeeFree](/guide/migration-from-beefree), [Stripo](/guide/migration-from-stripo), [Topol](/guide/migration-from-topol), [Chamaileon](/guide/migration-from-chamaileon), [Easy Email Pro](/guide/migration-from-easy-email-pro), [MJML](/guide/migration-from-mjml), [HTML](/guide/migration-from-html).

### Integrate the editor — or diagnose an existing integration

Ask it to scaffold a brand-new integration, add a save/load provider to an existing one, or explain why an editor mounted inside a modal renders its dialogs in the wrong place. For a new integration, it:

1. **Detects your stack** — package manager, framework, bundler, TypeScript, and whether `@templatical/editor` is already installed, and at what version.
2. **Proposes the change and waits** — the packages to add, the files to create or edit, the mount point — before touching anything.
3. **Writes it**, installing with your own package manager.
4. **Verifies by running your dev server** and reading its console and network output — not by asking you to check yourself.
5. **Reports** what changed, what was left alone, and what's still needed — a provider, an optional peer, a Cloud auth endpoint.

Diagnosing an existing integration runs the same steps backwards: it reads your `init()`/`initCloud()` call, bundler config and CSS setup, and checks each against a table of verified traps — duplicate Vue reactivity, a missing `style.css` import, a trapped `position: fixed` ancestor, and more.

## Good to know

### Bring your own branding

Layer your own context on top and the agent uses it instead of generic defaults — brand colors and fonts, tone of voice, a house system prompt, a mandatory footer or unsubscribe block. Worth doing once if you plan to generate more than one email.

### Never emit `custom`

Custom blocks are consumer-registered runtime extensions — the skill has no way to know what one does. Never emit `type: "custom"`. See [Custom Blocks](/guide/custom-blocks) for how to register your own.

### Never emit `countdown`

`countdown` needs Cloud's server-side animated GIF; the OSS renderer cannot produce it. If asked for a countdown, say so and offer a static stand-in — a title or paragraph with the date, or an "X days to go" line.

### Never emit `slot` or `wrapper`

`slot` and `wrapper` are layout markers on `init({ layout })`, not campaign blocks. Never emit `type: "slot"` or `type: "wrapper"`. See [Layout](/guide/layout).

### Prefer native blocks

Reach for `html` only as a last resort — raw HTML is not visually editable afterward.

### Match `settings.locale` to the copy

`settings.locale` becomes `<html lang>`. Set it to the language of the generated copy; do not default German copy to `"en"`. For `ar`/`he`/`fa`/`ur`/… also set `direction: "rtl"` (or omit it and those locales resolve as RTL).

### Shape of a template

The contract is `reference/schema.json` in the skill, also printed by `npx @templatical/template-tools schema`. Do not invent block fields from this page. A blank template is `{ blocks, settings }`:

```json
{
  "blocks": [],
  "settings": {
    "width": 600,
    "backgroundColor": "#ffffff",
    "textColor": "#1a1a1a",
    "linkUnderline": true,
    "fontFamily": "Arial",
    "locale": "en"
  }
}
```

---

# Block Types

Source: https://docs.templatical.com/guide/blocks

Blocks are the building units of every Templatical template. Each block represents a distinct piece of content -- a paragraph, an image, a button. Blocks can be placed directly in the template or inside sections for multi-column layouts. The editor renders them top-to-bottom in the order they appear.

Every block extends a common `Block` base with shared properties (`id`, `type`, `styles`, `displayCondition`, `visibility`), and each type adds its own specific properties.

To create blocks programmatically, see [Programmatic Templates](/guide/programmatic-templates). For default property values and how to customize them, see [Block & Template Defaults](/guide/defaults).

## Choosing the right block

| Need | Block | Notes |
|------|-------|-------|
| Headings, titles | [Title](#title) | Fixed-size headings (H1-H4) with block-level formatting |
| Body text, paragraphs | [Paragraph](#paragraph) | Rich text with inline formatting via TipTap |
| Photos, banners, logos | [Image](#image) | Optional link wrapping, responsive width |
| Call-to-action | [Button](#button) | Bulletproof buttons that work in all email clients |
| Multi-column layout | [Section](#section) | The only block that holds other blocks |
| Visual separation | [Divider](#divider) | Horizontal line with style options |
| Vertical spacing | [Spacer](#spacer) | Empty space between blocks |
| Social links | [Social Icons](#social-icons) | 17 platforms, 5 icon styles |
| Navigation links | [Menu](#menu) | Horizontal link list with separators |
| Tabular data | [Table](#table) | Data table with optional header styling |
| Video preview | [Video](#video) | Clickable thumbnail (email clients don't support embedded video) |
| Countdown to a deadline | [Countdown](#countdown) | Animated timer; rendering needs Templatical Cloud |
| Raw markup | [HTML](#html) | Escape hatch for custom code |
| Domain-specific content | [Custom](#custom) | Your own block types with fields and Liquid templates |

## Title

A heading block with fixed size levels. Use titles for headings, section headers, and other prominent text.

| Property | Type | Description |
|----------|------|-------------|
| `content` | `string` | HTML content |
| `level` | `1 \| 2 \| 3 \| 4` | Heading level (H1=36px, H2=28px, H3=22px, H4=18px) |
| `color` | `string` | Text color |
| `textAlign` | `'left' \| 'center' \| 'right'` | Horizontal alignment |
| `fontFamily` | `string` | Font family override |

## Paragraph

Body text rendered as HTML. The editor uses [Tiptap](https://tiptap.dev) for inline editing with formatting controls (bold, italic, links, alignment, font size, color, etc.). All formatting is applied inline -- there are no block-level formatting properties.

| Property | Type | Description |
|----------|------|-------------|
| `content` | `string` | HTML content |

## Image

Displays an image with optional link wrapping.

| Property | Type | Description |
|----------|------|-------------|
| `src` | `string` | Image URL |
| `alt` | `string` | Alt text |
| `width` | `number \| 'full'` | Display width in px, or `'full'` for 100% |
| `height` | `number` | Display height in px. Omit to derive it from the width and keep the aspect ratio |
| `align` | `'left' \| 'center' \| 'right'` | Horizontal alignment |
| `borderRadius` | `BorderRadiusValue` | Corner radius in px, or `{ topLeft, topRight, bottomRight, bottomLeft }` per corner. Omit or 0 for square corners |
| `border` | `BorderValue` | `{ top, right, bottom, left }`, each `{ width, style, color }`, drawn around the image. Width `0` leaves a side undrawn. Omit for no border |
| `decorative` | `boolean` | Hides the image from screen readers and sends an empty `alt` |
| `linkUrl` | `string` | Wraps image in a link |
| `linkOpenInNewTab` | `boolean` | Link target behavior |
| `placeholderUrl` | `string` | Placeholder shown in the editor when `src` uses a merge tag |

## Button

A call-to-action button with customizable appearance.

| Property | Type | Description |
|----------|------|-------------|
| `text` | `string` | Button label |
| `url` | `string` | Link URL |
| `backgroundColor` | `string` | Button background color |
| `textColor` | `string` | Button text color |
| `borderRadius` | `BorderRadiusValue` | Corner radius in px, or one per corner |
| `border` | `BorderValue` | `{ top, right, bottom, left }`, each `{ width, style, color }`, drawn around the button (optional). For an outline button, set `backgroundColor` to the keyword `"transparent"` in the template JSON, and set `textColor` too: a new button is `#333333` with white text |
| `fontSize` | `number` | Font size in px |
| `buttonPadding` | `SpacingValue` | Inner padding |
| `fontFamily` | `string` | Font family override |
| `openInNewTab` | `boolean` | Link target behavior |
| `width` | `number \| 'full'` | Fixed width in px, or `'full'` for 100%. Omit to size to content. |
| `align` | `'left' \| 'center' \| 'right'` | Placement within the column. No visible effect when `width` is `'full'`. |

## Divider

A horizontal line separator.

| Property | Type | Description |
|----------|------|-------------|
| `lineStyle` | `'solid' \| 'dashed' \| 'dotted'` | Line style |
| `color` | `string` | Line color |
| `thickness` | `number` | Line thickness in px |
| `width` | `number \| 'full'` | Line width in px, or `'full'` for 100% |

## Spacer

Empty vertical space.

| Property | Type | Description |
|----------|------|-------------|
| `height` | `number` | Height in px |

## HTML

Injects raw HTML into the template. Use this for content that cannot be expressed with other block types.

| Property | Type | Description |
|----------|------|-------------|
| `content` | `string` | Raw HTML markup |

## Social Icons

A row of social media icons linking to platform profiles.

| Property | Type | Description |
|----------|------|-------------|
| `icons` | `SocialIcon[]` | List of social icons |
| `iconStyle` | `'solid' \| 'outlined' \| 'rounded' \| 'square' \| 'circle'` | Visual style |
| `iconSize` | `'small' \| 'medium' \| 'large'` | Icon size |
| `spacing` | `number` | Space between icons in px |
| `align` | `'left' \| 'center' \| 'right'` | Horizontal alignment |

17 platforms are supported: Facebook, Twitter/X, Instagram, LinkedIn, YouTube, TikTok, Pinterest, Email, Website, WhatsApp, Telegram, Discord, Snapchat, Reddit, GitHub, Dribbble, and Behance.

Each `SocialIcon` has:

```ts
interface SocialIcon {
  id: string;
  platform: SocialPlatform;
  url: string;
}
```

## Menu

A horizontal navigation menu with text links.

| Property | Type | Description |
|----------|------|-------------|
| `items` | `MenuItemData[]` | Menu items |
| `fontSize` | `number` | Font size in px |
| `fontFamily` | `string` | Font family override |
| `color` | `string` | Text color |
| `linkColor` | `string` (optional) | Link color |
| `textAlign` | `'left' \| 'center' \| 'right'` | Alignment |
| `separator` | `string` | Character between items |
| `separatorColor` | `string` | Separator color |
| `spacing` | `number` | Space around separator |

Each `MenuItemData` has:

```ts
interface MenuItemData {
  id: string;
  text: string;
  url: string;
  openInNewTab: boolean;
  bold: boolean;
  underline: boolean;
  color?: string;
}
```

## Table

A data table with optional header row styling.

| Property | Type | Description |
|----------|------|-------------|
| `rows` | `TableRowData[]` | Table rows |
| `hasHeaderRow` | `boolean` | Style first row as header |
| `headerBackgroundColor` | `string` (optional) | Header row background |
| `borderColor` | `string` | Border color |
| `borderWidth` | `number` | Border width in px |
| `cellPadding` | `number` | Cell padding in px |
| `fontSize` | `number` | Font size in px |
| `fontFamily` | `string` | Font family override |
| `color` | `string` | Text color |
| `textAlign` | `'left' \| 'center' \| 'right'` | Cell text alignment |

## Video

Displays a video thumbnail that links to the video URL.

::: tip Email client note
Email clients do not support embedded video playback. The renderer outputs a clickable thumbnail image that links to the video URL. Always provide a good `thumbnailUrl` -- it's the only thing recipients see in their inbox.
:::

| Property | Type | Description |
|----------|------|-------------|
| `url` | `string` | Video URL (YouTube, Vimeo, etc.) |
| `thumbnailUrl` | `string` | Thumbnail image URL |
| `alt` | `string` | Alt text for thumbnail |
| `width` | `number \| 'full'` | Display width in px, or `'full'` for 100% |
| `height` | `number` | Display height in px. Omit to derive it from the width and keep the aspect ratio |
| `align` | `'left' \| 'center' \| 'right'` | Horizontal alignment |
| `openInNewTab` | `boolean` | Link target behavior |
| `placeholderUrl` | `string` | Editor-only placeholder |

## Countdown

A live countdown to a deadline, rendered as an animated GIF.

::: warning Rendering requires Templatical Cloud
An animated GIF has to be generated per recipient at send time, which a browser cannot do. The open-source renderer has no renderer for this block: it emits a `templatical:unrenderable-block` marker comment and logs a warning, so a send pipeline can detect and refuse it. See [Blocks with no renderer](/backend/render#blocks-with-no-renderer). On a hosted backend that can render GIFs, the block renders normally.
:::

| Property | Type | Description |
|----------|------|-------------|
| `targetDate` | `string` | ISO date/time the countdown runs to |
| `timezone` | `string` | IANA timezone the target is interpreted in |
| `showDays` / `showHours` / `showMinutes` / `showSeconds` | `boolean` | Which units to display |
| `labelDays` / `labelHours` / `labelMinutes` / `labelSeconds` | `string` | Caption under each unit |
| `separator` | `':' \| '-' \| ' '` | Character between units |
| `digitFontSize` | `number` | Digit size in px |
| `digitColor` | `string` | Digit colour |
| `labelFontSize` | `number` | Caption size in px |
| `labelColor` | `string` | Caption colour |
| `backgroundColor` | `string` | Block background |
| `fontFamily` | `string` (optional) | Font family override |
| `expiredMessage` | `string` | Shown once the target has passed |
| `expiredImageUrl` | `string` | Image shown instead of the timer once expired |
| `hideOnExpiry` | `boolean` | Hide the block entirely after the target passes |

## Section

A layout container that holds one or more columns. See [Sections and Columns](/guide/sections-and-columns) for full details.

| Property | Type | Description |
|----------|------|-------------|
| `columns` | `ColumnLayout` | Column layout preset |
| `children` | `Block[][]` | Array of block arrays, one per column |
| `stackOnMobile` | `boolean` | Omit or `true`: columns stack on mobile (MJML default). `false`: stay side by side (`mj-group`) |
| `borderRadius` | `BorderRadiusValue` | Corner radius in px, or one per corner (optional; omit or `0` for square corners) |
| `border` | `BorderValue` | `{ top, right, bottom, left }`, each `{ width, style, color }`, drawn around the section box (optional; omit for no border) |
| `wrapper` | `SectionWrapper` | Optional outer frame — `{ backgroundColor?, padding?, borderRadius? }` — rendered as an `mj-wrapper` band around the section. An embedder-owned card around the authored sections is a [layout](/guide/layout) overlay. |

::: tip Email client note: borders and corners in Outlook
- Outlook on Windows ignores `border-radius`, including the four-value (per-corner) form.
- Dashed and dotted borders compile, and Outlook's rendering engine often paints them solid.
- An image border is drawn on the `<img>`, and Outlook often drops it. Section and button borders sit on the `<td>`, which is the durable place for a border.
:::

## Custom

A user-defined block type powered by field definitions and a Liquid template. See [Custom Blocks](/guide/custom-blocks) for full details.

| Property | Type | Description |
|----------|------|-------------|
| `customType` | `string` | Unique identifier for the custom block type |
| `fieldValues` | `Record<string, unknown>` | Current values for defined fields |
| `renderedHtml` | `string` | Cached rendered output |
| `dataSourceFetched` | `boolean` | Whether the data source has been fetched |

---

# Custom Blocks

Source: https://docs.templatical.com/guide/custom-blocks

Custom blocks let you extend Templatical with your own block types. Define a set of fields, write a Liquid template for rendering, and optionally connect a data source. Users interact with custom blocks through the same drag-and-drop interface as built-in blocks.

::: warning Shadow DOM and host-side queries
Custom blocks render inside the editor's Shadow DOM by default. If your custom block needs to be reached from host page code (e.g. to wire up a third-party widget by ID), `document.querySelector` calls into the editor will not find it — walk the shadow root via `container.shadowRoot.querySelector(...)` instead, or opt out with `shadowDom: false`. See the [Shadow DOM guide](./shadow-dom) for the full host-integration story.
:::

## Defining a custom block

Pass custom block definitions through the editor config. The example below creates a "Testimonial" block with a quote, author details, avatar, and star rating. Once registered, users can drag it from the block palette into their template and edit each field from the settings panel.

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  customBlocks: [
    {
      type: 'testimonial',
      name: 'Testimonial',
      icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
      description: 'Customer quote with photo and rating',
      fields: [
        { key: 'quote', label: 'Quote', type: 'textarea' },
        { key: 'authorName', label: 'Author Name', type: 'text' },
        { key: 'authorTitle', label: 'Author Title', type: 'text' },
        { key: 'avatar', label: 'Avatar', type: 'image' },
        { key: 'rating', label: 'Rating (1-5)', type: 'number', min: 1, max: 5, step: 1, default: 5 },
        { key: 'showRating', label: 'Show Rating', type: 'boolean', default: true },
      ],
      template: `
        <table style="width: 100%; font-family: sans-serif;" cellpadding="0" cellspacing="0">
          <tr>
            <td style="padding: 20px; background: #f9fafb; border-radius: 8px;">
              {% if showRating %}
                <p style="margin: 0 0 12px; font-size: 18px; letter-spacing: 2px;">
                  {% if rating >= 1 %}&#9733;{% else %}&#9734;{% endif %}
                  {% if rating >= 2 %}&#9733;{% else %}&#9734;{% endif %}
                  {% if rating >= 3 %}&#9733;{% else %}&#9734;{% endif %}
                  {% if rating >= 4 %}&#9733;{% else %}&#9734;{% endif %}
                  {% if rating >= 5 %}&#9733;{% else %}&#9734;{% endif %}
                </p>
              {% endif %}
              <p style="margin: 0 0 16px; font-size: 16px; font-style: italic;
                        color: #333; line-height: 1.5;">"{{ quote }}"</p>
              <table cellpadding="0" cellspacing="0">
                <tr>
                  {% if avatar %}
                    <td style="vertical-align: middle; padding-right: 12px;">
                      <img src="{{ avatar }}" width="40" height="40"
                           style="border-radius: 50%; display: block;"
                           alt="{{ authorName }}" />
                    </td>
                  {% endif %}
                  <td style="vertical-align: middle;">
                    <p style="margin: 0; font-weight: 600; font-size: 14px;">
                      {{ authorName }}
                    </p>
                    {% if authorTitle %}
                      <p style="margin: 2px 0 0; font-size: 13px; color: #666;">
                        {{ authorTitle }}
                      </p>
                    {% endif %}
                  </td>
                </tr>
              </table>
            </td>
          </tr>
        </table>
      `,
    },
  ],
});
```

![Custom blocks overview](/images/custom-blocks-overview.png)

1. **Block palette** — The `name` and `icon` appear here. Users drag the block from the palette into their template.
2. **Settings panel** — Each entry in `fields` becomes a form control where users can edit the block's content.
3. **Editor canvas** — The Liquid `template` renders exactly as defined, with field values populated from the settings panel.

::: tip Reordering the palette
By default a custom block appears in the palette after all built-in blocks. To promote it — or to hide built-in types you don't use — pass the [`paletteBlocks`](/api/editor#customizing-the-block-palette) option and reference your custom block by its `custom:`-prefixed type (e.g. `"custom:testimonial"`).
:::

## CustomBlockDefinition

```ts
interface CustomBlockDefinition {
  type: string;
  name: string;
  icon?: string;
  description?: string;
  fields: CustomBlockField[];
  template: string;
  dataSource?: DataSourceConfig;
  defaultStyles?: Partial<BlockStyles>;
  stylesheet?: string;
}
```

| Property | Required | Description |
|----------|----------|-------------|
| `type` | Yes | Unique identifier (used as `customType` on block instances) |
| `name` | Yes | Display name in the block palette |
| `icon` | No | Inline SVG string, image URL, or base64 data URI for the palette icon |
| `description` | No | Tooltip or subtitle in the palette |
| `fields` | Yes | Array of field definitions |
| `template` | Yes | Liquid template string for rendering |
| `dataSource` | No | External data fetching configuration |
| `defaultStyles` | No | Default block styles (padding, backgroundColor) — see [Default styles](#default-styles) |
| `stylesheet` | No | Definition-level CSS emitted once into the rendered MJML's `<mj-head>` and mirrored in the editor canvas — see [Stylesheets](#stylesheets) |

## Field types

Each field defines a form control in the block settings panel.

```ts
interface CustomBlockFieldBase {
  key: string;
  label: string;
  required?: boolean;
  placeholder?: string;
  readOnly?: boolean;
}
```

All field types extend this base. The `key` is used as the variable name in your Liquid template. Additional properties depend on the field `type`:

| Property | Applies to | Description |
|----------|------------|-------------|
| `required` | All | Mark the field as required |
| `placeholder` | All | Placeholder text for the input |
| `readOnly` | All | Prevent user editing (useful with data sources) |
| `default` | All | Default value when the block is created |
| `min`, `max`, `step` | `number` | Numeric constraints |
| `options` | `select` | Array of `{ label, value }` choices |
| `fields` | `repeatable` | Sub-field definitions |
| `minItems`, `maxItems` | `repeatable` | Item count bounds |

### text

Single-line text input.

```ts
{
  key: 'title',
  label: 'Title',
  type: 'text',
  default: 'Untitled',
}
```

### textarea

Multi-line text input for longer content.

```ts
{
  key: 'body',
  label: 'Body Text',
  type: 'textarea',
}
```

### image

Image URL input with upload/browse integration.

```ts
{
  key: 'heroImage',
  label: 'Hero Image',
  type: 'image',
}
```

### color

Color picker input.

```ts
{
  key: 'accentColor',
  label: 'Accent Color',
  type: 'color',
  default: '#6366f1',
}
```

Scope a color field to a specific color role — or lock it to one — with `presets` and `allowCustom`. They take the same shape as the editor-wide [`colors`](/api/editor#preset-colors) config but apply to this field only, so one field can offer an accent/ink pair while every other color field keeps the global palette:

```ts
{
  key: 'buttonColor',
  label: 'Button Color',
  type: 'color',
  presets: ['#0b5cff', '#111827'],
  allowCustom: false, // authors pick only from the two brand colors
}
```

A field config refines the editor-wide one — it can restrict what authors may do, but never unlock what the editor locked:

- **Unset inherits.** A field that sets neither gets the editor's palette and its `allowCustom` setting.
- **`presets`** must be `#rgb` / `#rrggbb` hex, validated exactly like editor-level presets — invalid entries are skipped with a console warning naming the block and the field. An empty list (`presets: []`) or a list whose entries are all invalid narrows nothing, so the field falls back to the editor's palette (also warned).
- **`allowCustom: false`** locks this field to its palette even while the rest of the editor allows free-form entry. **`allowCustom: true` cannot unlock a field when the editor-wide `colors.allowCustom` is `false`** — that request is ignored with a warning.
- **Off-palette defaults are flagged.** When the field ends up locked, a `default` outside its effective palette is warned about: new blocks would start on a color the picker offers no chip for.

A field's `presets` **replace** the editor palette for that field rather than intersecting it, so a locked field can carry its own colors: with the editor locked to `['#0b5cff', '#111827']` and a field set to `presets: ['#e11d48']`, that field's picker offers `#e11d48` alone, plus the leading "no color" chip every locked picker renders. What a field restricts is the author's freedom, not the size or membership of the palette — a field may list more colors than the editor-wide grid, and what it can never do is unlock a locked editor.

Narrowing a palette on a template that already has saved content can leave a stored color unselectable in locked mode — the picker then shows no chip checked; clearing the value with the leading "no color" chip re-enters the palette.

### number

Numeric input with optional min, max, and step constraints.

```ts
{
  key: 'rating',
  label: 'Rating',
  type: 'number',
  min: 1,
  max: 5,
  step: 1,
}
```

### select

Dropdown with predefined options.

```ts
{
  key: 'size',
  label: 'Size',
  type: 'select',
  options: [
    { label: 'Small', value: 'sm' },
    { label: 'Medium', value: 'md' },
    { label: 'Large', value: 'lg' },
  ],
  default: 'md',
}
```

### boolean

Toggle switch.

```ts
{
  key: 'showBadge',
  label: 'Show Badge',
  type: 'boolean',
  default: true,
}
```

### repeatable

A repeatable group of sub-fields. Users can add or remove items within the configured bounds. Each entry in `fields` follows the same field configuration as top-level fields. Nested repeatables are not supported.

```ts
{
  key: 'features',
  label: 'Features',
  type: 'repeatable',
  minItems: 1,
  maxItems: 5,
  fields: [
    { key: 'icon', label: 'Icon', type: 'image' },
    { key: 'text', label: 'Text', type: 'text' },
  ],
}
```

## Default styles

Use `defaultStyles` to declare the block's default `padding` and `backgroundColor` alongside the rest of the definition. The value is a `Partial<BlockStyles>` — specify only the fields you want to override; everything else falls back to the built-in defaults (`10px` padding all around, no background).

```ts
{
  type: 'pricing-table',
  name: 'Pricing Table',
  fields: [/* … */],
  template: '<table>…</table>',
  defaultStyles: {
    padding: { top: 0, right: 0, bottom: 0, left: 0 },
  },
}
```

`defaultStyles` applies the moment a user drags the block from the palette onto the canvas. Users can still adjust padding/background per block instance through the block settings — `defaultStyles` only controls where each new instance starts.

This is the recommended way to opt custom blocks out of the SDK's default wrapper padding when the block's Liquid template already manages its own spacing (e.g. hero sections, product cards, or anything authored as full table HTML).

::: warning Existing blocks don't retroactively pick up `defaultStyles` changes
`defaultStyles` is applied once, when a new block instance is created from the palette. Blocks already on the canvas — or in saved templates — keep the styles they were created with. To see a `defaultStyles` change reflected on an existing block, delete it and drag a fresh instance. This is intentional: it prevents per-instance spacing tweaks made by end-users from being silently overwritten when a developer edits the definition.
:::

## Stylesheets

The renderer wraps your `template` output in an `mj-text` cell, which means MJML's automatic responsive behavior — column stacking, fluid images, mobile-first padding — applies to the *outer* layout but **does not reach inside** your custom block's HTML. A two-column custom block with a `<table>` layout, for example, needs to opt into its own mobile stacking via a media query.

Use `stylesheet` on the definition for CSS that should apply to all instances of the block: media queries, hover states, custom-block-specific font declarations, anchor color overrides. The renderer collects every definition's `stylesheet` from the content tree, dedupes (per `customType` and again per trimmed content), and emits each unique stylesheet **once** as `<mj-style>` inside `<mj-head>` — regardless of how many instances of the block the template contains. The editor canvas mirrors the same CSS so authored responsive behavior previews live.

```ts
{
  type: 'image-text',
  name: 'Image + Text',
  fields: [
    { key: 'image', type: 'image', label: 'Image' },
    { key: 'heading', type: 'text', label: 'Heading' },
    { key: 'body', type: 'textarea', label: 'Body' },
  ],
  template: `
    <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
      <tr>
        <td class="tplc-image-text-cell tplc-image-text-cell--media" style="width: 40%; vertical-align: top;">
          <img src="{{ image }}" width="200" style="display: block; max-width: 100%;" />
        </td>
        <td class="tplc-image-text-cell tplc-image-text-cell--body" style="width: 60%; padding-left: 20px; vertical-align: top;">
          <h3 style="margin: 0 0 8px;">{{ heading }}</h3>
          <p style="margin: 0;">{{ body }}</p>
        </td>
      </tr>
    </table>
  `,
  stylesheet: `
    @media (max-width: 480px) {
      .tplc-image-text-cell {
        display: block !important;
        width: 100% !important;
        padding-left: 0 !important;
      }
      .tplc-image-text-cell--body {
        padding-top: 16px !important;
      }
    }
  `,
}
```

::: tip Namespace your class names
The SDK does not scope `stylesheet` selectors automatically. Two definitions that use the same class name will collide in the emitted `<mj-head>`. Prefix your classes per definition — the `tplc-<type>-<element>` convention (`tplc-image-text-cell`, `tplc-product-card-button`, …) keeps each definition's CSS isolated without runtime work.
:::

::: warning Email client caveats apply
Like all CSS in an email, your `stylesheet` rules are subject to per-client support. Notably, **Outlook on Windows (2007–2021, Word rendering engine) ignores `@media` queries entirely** — your block keeps its desktop layout there. That's the expected fallback: Outlook desktop is a desktop client, so retaining the desktop styling is usually the right outcome. Mobile clients (iOS Mail, Apple Mail, Gmail, Outlook mobile) support media queries and apply your responsive rules normally.

Stick to widely-supported CSS for the best results: `display`, `width`, `padding`, `background-color`, `border`, simple media queries on `max-width`. Avoid `flex`, `grid`, `position`, CSS animations, and similar advanced features in email contexts.
:::

## Liquid templates

The `template` property uses [Liquid](https://liquidjs.com/) syntax. Field values are available as top-level variables matching their `key` property. Here are the most common patterns:

**Output a variable:**

```liquid
<p>{{ title }}</p>
```

**Conditionals:**

```liquid
{% if showBadge %}
  <span>New</span>
{% endif %}

{% if price > 100 %}
  <p>Premium item</p>
{% elsif price > 50 %}
  <p>Mid-range item</p>
{% else %}
  <p>Budget item</p>
{% endif %}
```

**Loops** (for repeatable fields):

```liquid
{% for feature in features %}
  <p>{{ feature.text }}</p>
{% endfor %}
```

**Default values:**

```liquid
<p>{{ title | default: "Untitled" }}</p>
```

**String filters:**

```liquid
<p>{{ name | upcase }}</p>
<p>{{ description | truncate: 100 }}</p>
```

**Comparisons:** `==`, `!=`, `>`, `<`, `>=`, `<=`, `and`, `or`, `contains`

For the full syntax reference, see the [Liquid documentation](https://liquidjs.com/tags/overview.html).

## Data sources

Custom blocks become even more powerful when backed by an API data source. Instead of manually filling in every field, a data source fetches external data and populates the block automatically. When the block is added to the canvas (or when the user triggers a refresh), the `onFetch` callback is called and the returned data populates the block's field values.

```ts
interface DataSourceConfig {
  label: string;
  onFetch: (context: DataSourceFetchContext) => Promise<Record<string, unknown> | null>;
}

interface DataSourceFetchContext {
  fieldValues: Record<string, unknown>;
  blockId: string;
}
```

The `label` is displayed on the fetch button in the editor UI. The `onFetch` callback receives a context object with the block's current `fieldValues` and `blockId`. Return the fetched data as an object, or `null` if the fetch should be skipped.

```ts
{
  type: 'product-card',
  name: 'Product Card',
  fields: [
    { key: 'productId', label: 'Product ID', type: 'text', readOnly: true },
    { key: 'imageUrl', label: 'Image', type: 'image', readOnly: true },
    { key: 'name', label: 'Product Name', type: 'text' },
    { key: 'price', label: 'Price', type: 'text' },
  ],
  template: `
    <div style="text-align: center; font-family: sans-serif;">
      <img src="{{ imageUrl }}" alt="{{ name }}" style="max-width: 100%;" />
      <h2 style="margin: 16px 0 8px;">{{ name }}</h2>
      <p style="font-size: 24px; font-weight: bold;">{{ price }}</p>
    </div>
  `,
  dataSource: {
    label: 'Fetch Product',
    // Called when user clicks the "Fetch Product" button in the editor.
    // Use this to open a custom picker, call your API, or load data
    // from any external source. Return an object with keys matching
    // your field definitions to populate the block, or null to cancel.
    onFetch: async ({ fieldValues }) => {
      const product = await showMyProductPicker();
      if (!product) return null;
      return {
        productId: product.id,
        imageUrl: product.image_url,
        name: product.name,
        price: `$${product.price.toFixed(2)}`,
      };
    },
  },
}
```

The returned object is merged into the block's `fieldValues` — the keys in the returned object should match the `key` properties of your field definitions. The block's `dataSourceFetched` flag is then set to `true`.

::: warning Data is fetched once, not dynamically
Data fetching is a one-time operation to populate the fields from an external source. Once fetched, the values are saved directly into the template like any other field data. The data source is not called dynamically at render time — users can still manually edit the populated values afterward unless the field is marked as `readOnly`.
:::

## More examples

### Event card

An event invitation block with a schedule built using repeatable fields:

```ts
const eventCard: CustomBlockDefinition = {
  type: 'event-card',
  name: 'Event Card',
  icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
  description: 'Event details with schedule and RSVP',
  fields: [
    { key: 'eventName', label: 'Event Name', type: 'text', default: 'Untitled Event' },
    { key: 'date', label: 'Date', type: 'text', default: 'January 1, 2026' },
    { key: 'venue', label: 'Venue', type: 'text' },
    { key: 'venueAddress', label: 'Venue Address', type: 'text' },
    { key: 'accentColor', label: 'Accent Color', type: 'color', default: '#4f46e5' },
    { key: 'rsvpUrl', label: 'RSVP URL', type: 'text' },
    {
      key: 'schedule',
      label: 'Schedule',
      type: 'repeatable',
      minItems: 1,
      maxItems: 10,
      fields: [
        { key: 'time', label: 'Time', type: 'text' },
        { key: 'session', label: 'Session', type: 'text' },
      ],
    },
  ],
  template: `
    <div style="border-left: 4px solid {{ accentColor }}; padding: 16px 20px; font-family: sans-serif;">
      <h2 style="margin: 0 0 4px; font-size: 20px;">{{ eventName }}</h2>
      <p style="color: #666; margin: 0 0 12px; font-size: 14px;">{{ date }}{% if venue %} &middot; {{ venue }}{% endif %}</p>
      {% if venueAddress %}
        <p style="color: #999; margin: 0 0 16px; font-size: 13px;">{{ venueAddress }}</p>
      {% endif %}
      <table style="width: 100%; border-collapse: collapse; margin-bottom: 16px;">
        {% for item in schedule %}
        <tr>
          <td style="padding: 6px 12px 6px 0; font-size: 13px; color: #666; white-space: nowrap; vertical-align: top;">{{ item.time }}</td>
          <td style="padding: 6px 0; font-size: 14px;{% unless forloop.last %} border-bottom: 1px solid #eee;{% endunless %}">{{ item.session }}</td>
        </tr>
        {% endfor %}
      </table>
      {% if rsvpUrl %}
        <a href="{{ rsvpUrl }}" style="display: inline-block; padding: 10px 24px; background: {{ accentColor }}; color: #fff; text-decoration: none; border-radius: 6px; font-size: 14px;">RSVP Now</a>
      {% endif %}
    </div>
  `,
};
```

### Pricing tier

A pricing block with a feature list and CTA button:

```ts
const pricingTier: CustomBlockDefinition = {
  type: 'pricing-tier',
  name: 'Pricing Tier',
  icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
  description: 'Pricing card with features list',
  fields: [
    { key: 'planName', label: 'Plan Name', type: 'text', default: 'Pro' },
    { key: 'price', label: 'Price', type: 'text', default: '$29/mo' },
    { key: 'highlighted', label: 'Highlighted', type: 'boolean', default: false },
    { key: 'accentColor', label: 'Accent Color', type: 'color', default: '#4f46e5' },
    { key: 'ctaLabel', label: 'Button Label', type: 'text', default: 'Get Started' },
    { key: 'ctaUrl', label: 'Button URL', type: 'text' },
    {
      key: 'features',
      label: 'Features',
      type: 'repeatable',
      minItems: 1,
      maxItems: 8,
      fields: [
        { key: 'text', label: 'Feature', type: 'text' },
      ],
    },
  ],
  template: `
    <div style="font-family: sans-serif; border: {% if highlighted %}2px solid {{ accentColor }}{% else %}1px solid #e5e7eb{% endif %}; border-radius: 8px; padding: 24px; text-align: center;">
      <h3 style="margin: 0 0 4px; font-size: 18px;">{{ planName }}</h3>
      <p style="font-size: 32px; font-weight: bold; margin: 8px 0 16px;">{{ price }}</p>
      <table style="width: 100%; text-align: left; margin-bottom: 20px;">
        {% for feature in features %}
        <tr>
          <td style="padding: 6px 0; font-size: 14px; color: #333;">&#10003; {{ feature.text }}</td>
        </tr>
        {% endfor %}
      </table>
      {% if ctaUrl %}
        <a href="{{ ctaUrl }}" style="display: inline-block; padding: 10px 24px; background: {{ accentColor }}; color: #fff; text-decoration: none; border-radius: 6px; font-size: 14px;">{{ ctaLabel }}</a>
      {% endif %}
    </div>
  `,
};
```

## Email compatibility tips

Custom block templates are rendered as raw HTML inside the email. Keep these guidelines in mind:

- **Use inline styles** -- Email clients strip `<style>` blocks. Put all styling in `style` attributes.
- **Use tables for layout** -- Flexbox and CSS Grid don't work in most email clients (especially Outlook). Use `<table>` for side-by-side layouts.
- **Avoid complex CSS** -- No `position`, `float`, `display: flex`, `box-shadow`, or CSS animations. Stick to `padding`, `margin`, `border`, `background-color`, `color`, `font-size`, and `text-align`.
- **Test in real clients** -- Outlook, Gmail, Apple Mail, and Yahoo all render HTML differently. Use an email testing tool to preview across clients.

## Error handling in data sources

The `onFetch` callback should handle errors gracefully. If the fetch fails, return an empty object or partial data -- the block will render with whatever field values are available:

```ts
dataSource: {
  label: 'Fetch Product',
  onFetch: async ({ fieldValues }) => {
    try {
      const res = await fetch(`/api/products/${fieldValues.productId}`);
      if (!res.ok) return null;
      return await res.json();
    } catch {
      return null; // Block renders with existing field values
    }
  },
},
```

---

# Block & Template Defaults

Source: https://docs.templatical.com/guide/defaults

Block properties (colors, font sizes, padding, placeholder text, etc.) are hardcoded in factory functions. `blockDefaults` and `templateDefaults` let you override these so every new block and template matches your brand out of the box.

## Block Defaults

Pass a `blockDefaults` object to `init()`. Each key maps to a block type and accepts a partial override of that block's properties:

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  blockDefaults: {
    title: { color: '#000000' },
    paragraph: {},
    button: {
      backgroundColor: '#ff6600',
      textColor: '#ffffff',
      styles: { padding: { top: 20, bottom: 20 } },
    },
    divider: { color: '#eeeeee', thickness: 2 },
    image: { alt: 'Brand image' },
  },
});
```

Defaults apply when:
- Dragging a block from the sidebar
- Calling `createAndAddBlock()` programmatically

Defaults do **not** apply when:
- Duplicating an existing block (the source block's values are preserved)
- Loading saved content from the API

### Placeholder text

The text a new block starts with is localized, so `blockDefaults` is an override
of a *localized* value rather than of an English one:

```ts
const editor = await init({
  container: '#editor',
  locale: 'de',
  blockDefaults: {
    // Without these, a new Title reads "Geben Sie Ihren Titel ein" and a new
    // Button "Hier klicken".
    title: { content: '<p>Ihre Überschrift</p>' },
    paragraph: { content: '<p>Ihr Text</p>' },
    button: { text: 'Jetzt kaufen' },
    video: { alt: 'Produktvideo' },
  },
});
```

`title.content` and `paragraph.content` are rich-text HTML — wrap the text in
`<p>…</p>`. Every other field above is a plain string.

Which locale a default follows depends on whether the text is for the author or
for the recipient; [Internationalization](/guide/i18n) has the table. Values you
set here win over both.

### Deep Merge Behavior

Nested objects are **deep-merged**, not shallow-replaced. This means you can override a single padding value without losing the rest:

```ts
blockDefaults: {
  button: {
    // Only top padding changes. right/bottom/left keep their defaults.
    styles: { padding: { top: 20 } },
    // Same for buttonPadding — only top changes.
    buttonPadding: { top: 16 },
  },
}
```

Arrays are **replaced**, not merged. For example, setting `table.rows` replaces the entire default rows array.

### Supported Block Types

| Key | Block Type |
|-----|-----------|
| `title` | Title |
| `paragraph` | Paragraph |
| `image` | Image |
| `button` | Button |
| `divider` | Divider |
| `section` | Section |
| `video` | Video |
| `social` | Social Icons |
| `spacer` | Spacer |
| `html` | HTML |
| `menu` | Menu |
| `table` | Table |

Custom blocks are not affected by `blockDefaults`. They use their own `default` values defined in the `CustomBlockDefinition` field configuration.

### TypeScript Type

```ts
import type { BlockDefaults } from '@templatical/editor';
// or
import type { BlockDefaults } from '@templatical/types';
```

Each block type key accepts `Partial<Omit<BlockType, 'id' | 'type'>>` — you can override any property except `id` and `type`, which are always generated automatically. See [Block Types](/guide/blocks) for the full list of available properties per block.

## Template Defaults

Pass a `templateDefaults` object to override the default template settings used when creating a new empty template:

```ts
const editor = await init({
  container: '#editor',
  templateDefaults: {
    width: 640,
    backgroundColor: '#f5f5f5',
    fontFamily: 'Helvetica, sans-serif',
    preheaderText: 'Check out our latest news',
  },
});
```

### When Template Defaults Apply

- **No content provided** — `templateDefaults` override the hardcoded factory defaults (width: 600, bg: #ffffff, etc.).
- **Content provided** — The provided content's settings are used as-is. `templateDefaults` have no effect.

In other words, `templateDefaults` are fallbacks for missing content, not overrides for existing content.

### Available Settings

| Property | Default | Description |
|----------|---------|-------------|
| `width` | `600` | Template width in pixels |
| `backgroundColor` | `#ffffff` | Template background color |
| `fontFamily` | `Arial` | Default font family |
| `locale` | `en` | BCP-47 content language (`<html lang>`) |
| `direction` | — | `"ltr"` or `"rtl"`. Unset follows the content language |
| `preheaderText` | — | Email preheader text |

### TypeScript Type

```ts
import type { TemplateDefaults } from '@templatical/editor';
// or
import type { TemplateDefaults } from '@templatical/types';
```

### Hiding a Setting

`templateDefaults` sets a starting value; it does not decide whether the author can change it. To take a setting out of the Settings panel, pass an allowlist to `templateSettings.fields`:

```ts
const editor = await init({
  container: '#editor',
  // The author picks a width; locale and preheader come from the application.
  templateSettings: { fields: ['width', 'backgroundColor', 'fontFamily'] },
});
```

The two compose in one direction only. `templateDefaults` applies when no content is provided, so it cannot pin a setting on a template you load — set that from the content itself (`init({ content })`, or your `templates` provider's `load`). See [Restricting the settings panel](/api/editor#restricting-the-settings-panel) for the full list of fields and how cards follow them.

## Built-in Default Constants

The SDK exports the built-in default values for every block type and template settings as constants. Use these to inspect current defaults, extend them, or build custom presets:

```ts
import {
  DEFAULT_BLOCK_DEFAULTS,
  DEFAULT_TEMPLATE_DEFAULTS,
  TITLE_BLOCK_DEFAULTS,
  PARAGRAPH_BLOCK_DEFAULTS,
  BUTTON_BLOCK_DEFAULTS,
} from '@templatical/types';

// Inspect the defaults for a single block type
console.log(TITLE_BLOCK_DEFAULTS);
// { content: '<p>Enter your title</p>', level: 2, color: '#1a1a1a', ... }

// Inspect template defaults
console.log(DEFAULT_TEMPLATE_DEFAULTS);
// { width: 600, backgroundColor: '#ffffff', textColor: '#1a1a1a',
//   linkUnderline: true, fontFamily: 'Arial', locale: 'en' }

// Build a custom preset by extending a single block's defaults
const myButtonDefaults = {
  ...BUTTON_BLOCK_DEFAULTS,
  backgroundColor: '#ff6600',
  borderRadius: 8,
};
```

### Available Constants

**Per-block constants** — each contains the default property values for that block type (excluding `id`, `type`, and `styles`):

| Constant | Block Type |
|----------|-----------|
| `TITLE_BLOCK_DEFAULTS` | Title |
| `PARAGRAPH_BLOCK_DEFAULTS` | Paragraph |
| `IMAGE_BLOCK_DEFAULTS` | Image |
| `BUTTON_BLOCK_DEFAULTS` | Button |
| `DIVIDER_BLOCK_DEFAULTS` | Divider |
| `SECTION_BLOCK_DEFAULTS` | Section |
| `VIDEO_BLOCK_DEFAULTS` | Video |
| `SOCIAL_ICONS_BLOCK_DEFAULTS` | Social Icons |
| `SPACER_BLOCK_DEFAULTS` | Spacer |
| `HTML_BLOCK_DEFAULTS` | HTML |
| `MENU_BLOCK_DEFAULTS` | Menu |
| `TABLE_BLOCK_DEFAULTS` | Table |

**Combined constants:**

| Constant | Description |
|----------|-------------|
| `DEFAULT_BLOCK_DEFAULTS` | All block type defaults in a single object (keys match `BlockDefaults` interface) |
| `DEFAULT_TEMPLATE_DEFAULTS` | Template settings defaults (`width`, `backgroundColor`, `fontFamily`) |

These constants are the single source of truth used internally by the factory functions. If you only need to override a few properties, you don't need to reference them — just pass your overrides to `blockDefaults` and they'll be deep-merged with these values.

## Programmatic Usage

The underlying factory functions also accept defaults directly:

```ts
import { createBlock, createTitleBlock, createParagraphBlock, createDefaultTemplateContent } from '@templatical/types';
import type { BlockDefaults } from '@templatical/types';

// Single block with partial overrides
const title = createTitleBlock({ level: 2, color: '#000' });
const paragraph = createParagraphBlock({ content: '<p>Hello</p>' });

// Via createBlock with full defaults map
const defaults: BlockDefaults = {
  title: { color: '#000000' },
  button: { backgroundColor: '#ff6600' },
};
const block = createBlock('title', defaults);

// Template with custom settings
const content = createDefaultTemplateContent('Helvetica, sans-serif', {
  width: 640,
  backgroundColor: '#f5f5f5',
});
```

---

# Display Conditions

Source: https://docs.templatical.com/guide/display-conditions

Display conditions allow users to change block visibility based on conditions. When a condition is applied to a block, the renderer wraps its output in the conditional syntax you define (e.g., Liquid `{% if %}` / `{% endif %}`). The conditions pass through unchanged — your sending platform or templating engine should render them before sending.

## Configuration

Define available conditions through the editor config:

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  displayConditions: {
    conditions: [
      {
        label: 'VIP Customers',
        before: '{% if customer.vip == true %}',
        after: '{% endif %}',
        description: 'Only shown to VIP customers',
      },
      {
        label: 'Has Active Subscription',
        before: '{% if subscription.active %}',
        after: '{% endif %}',
        description: 'Shown when user has an active subscription',
      },
    ],
  },
});
```

## DisplayCondition type

```ts
interface DisplayCondition {
  label: string;
  before: string;
  after: string;
  group?: string;
  description?: string;
}
```

| Property | Required | Description |
|----------|----------|-------------|
| `label` | Yes | Display name shown in the editor UI |
| `before` | Yes | Markup inserted before the block output |
| `after` | Yes | Markup inserted after the block output |
| `group` | No | Group name for organizing conditions in the dropdown |
| `description` | No | Explanatory text shown below the label |

## DisplayConditionsConfig

```ts
interface DisplayConditionsConfig {
  conditions: DisplayCondition[];
  allowCustom?: boolean;
}
```

When `allowCustom` is `true`, users can write their own `before` and `after` strings directly in the editor instead of choosing from the predefined list. Defaults to `false`.

## Grouping conditions

Use the `group` property to organize conditions into logical sections. Groups appear as labeled sections in the condition picker dropdown.

```ts
displayConditions: {
  allowCustom: true,
  conditions: [
    {
      label: 'VIP Partners',
      before: '{% if customer.vip %}',
      after: '{% endif %}',
      group: 'Audience',
    },
    {
      label: 'Free Users',
      before: '{% if customer.plan == "free" %}',
      after: '{% endif %}',
      group: 'Audience',
    },
    {
      label: 'Enterprise',
      before: '{% if customer.plan == "enterprise" %}',
      after: '{% endif %}',
      group: 'Audience',
    },
    {
      label: 'Beta Testers',
      before: '{% if customer.beta %}',
      after: '{% endif %}',
      group: 'Audience',
    },
    {
      label: 'Early Bird',
      before: '{% if registration.early_bird %}',
      after: '{% endif %}',
      group: 'Registration',
    },
    {
      label: 'Speakers',
      before: '{% if role == "speaker" %}',
      after: '{% endif %}',
      group: 'Role',
    },
  ],
}
```

<img src="/images/display-condition-grouping.png" alt="Grouped display conditions in the dropdown" style="max-width: 360px;" />

The `before` and `after` fields accept any string, so you can use any templating syntax your platform supports — Liquid, Handlebars, AMPscript, Jinja2, or anything else.

## Output

When a block has a display condition assigned, the renderer wraps the block's HTML output with the `before` and `after` strings:

```html
{% if customer.vip == true %}
<tr>
  <td>
    <!-- block content here -->
  </td>
</tr>
{% endif %}
```

The editor and renderer treat the `before` and `after` strings as opaque — they are inserted exactly as defined.

## Applying conditions in the editor

Users select a display condition from the block settings panel. Each block can have at most one display condition. In the editor canvas, blocks with conditions show a filter icon so it's easy to see which content is conditional at a glance.

<img src="/images/display-condition-icon.png" alt="Display condition icon on a block" style="max-width: 360px;" />

Clicking the filter icon hides the block, simulating a falsy condition. This lets users preview how the template looks when certain conditional blocks are not visible. When any block is hidden this way, a restore button appears in the editor to bring all hidden blocks back.

<img src="/images/display-condition-restore.png" alt="Restore hidden conditions button" style="max-width: 360px;" />

### Evaluating conditions for real

The filter icon **simulates** a falsy condition — it's a manual what-if, and nothing checks the condition against data. To have conditions actually evaluated, wire the [`resolvePreview`](/guide/preview-rendering) hook: your backend receives the template and can omit blocks whose condition doesn't hold for a given recipient, so the preview shows the real variation rather than one you toggled by hand.

While a resolver is showing the preview, the filter icon and the restore button step aside — the resolver has already checked each condition against real data, so a hand-toggled hide would override the answer you asked for. Your hidden blocks come back when you leave the preview.

::: tip Checking each variation
Two ways, and they answer different questions:

- **[Preview Rendering](/guide/preview-rendering)** — wire `resolvePreview` and each variation renders in the editor, without sending anything. Fastest loop while authoring, and the only one that shows a variation for a recipient you can't mail.
- **[Test emails](/backend/test-email)** — send to different recipient profiles and confirm each renders in a real inbox. Slower, but it exercises your platform's own evaluation and the mail client, which no preview can.
  :::

---

# Custom Fonts

Source: https://docs.templatical.com/guide/fonts

By default, the editor includes a set of common web-safe fonts (Arial, Georgia, Verdana, etc.) in the font picker. You can extend this list with your own fonts — for example, loading custom typefaces from Google Fonts or your own CDN. When a custom font is used, it's automatically included as an `<mj-font>` declaration in the rendered MJML output.

Configure which fonts are available using the `fonts` option:

```ts
import type { FontsConfig } from '@templatical/types';

const fonts: FontsConfig = {
  defaultFont: 'Inter',
  defaultFallback: 'Arial, sans-serif',
  customFonts: [
    {
      name: 'Inter',
      url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap',
      fallback: 'Helvetica, Arial, sans-serif',
    },
    {
      name: 'Merriweather',
      url: 'https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap',
      fallback: 'Georgia, serif',
    },
  ],
};

const editor = await init({
  container: '#editor',
  fonts,
});
```

## FontsConfig

| Property | Type | Description |
|---|---|---|
| `defaultFont` | `string` | Font name selected by default in new templates |
| `defaultFallback` | `string` | Fallback stack used when a custom font is unavailable |
| `customFonts` | `CustomFont[]` | List of custom fonts to register |
| `builtIns` | `boolean \| string[]` | Restrict which built-in fonts appear in the picker (see [below](#restricting-the-built-in-fonts)) |

## CustomFont

| Property | Type | Description |
|---|---|---|
| `name` | `string` | Display name in the font picker |
| `url` | `string` | URL to the font CSS (e.g., Google Fonts link) |
| `fallback` | `string` | Optional fallback font stack for this font |

Custom fonts are automatically included as `<mj-font>` declarations in the rendered MJML output.

## Restricting the built-in fonts

By default the picker offers all seven built-in web-safe fonts (Arial, Helvetica, Georgia, Times New Roman, Verdana, Trebuchet MS, Courier New) alongside your `customFonts`. Use `builtIns` to narrow that list — useful when you embed the editor as a white-label / brand-kit tool and want authors to stay on approved typefaces.

```ts
const fonts: FontsConfig = {
  // Keep only these built-ins, plus any customFonts:
  builtIns: ['Georgia', 'Arial'],
  // Or drop the built-ins entirely and offer only your custom fonts:
  // builtIns: false,
  customFonts: [
    /* ... */
  ],
};
```

- `true` or omitted — all seven built-ins are offered (the default).
- `false` — no built-ins; the picker lists only your `customFonts`.
- `string[]` — an allowlist of built-in names to keep, matched case-insensitively. A name that isn't a built-in is logged with a warning and skipped.

Restricting the list only affects the picker. A custom font can still be the `defaultFont` when every built-in is excluded, and content already using a hidden built-in still renders with its proper fallback stack.

If you exclude the family that new templates start on — Arial by default, or whatever you set as `defaultFont` — the editor logs a development warning at startup, because authors would land on a font the picker can't reselect. Either keep that family in `builtIns`, or set `defaultFont` to one you do offer.

## Best practices

- **Always set a fallback** — Most email clients don't support custom fonts. The fallback stack ensures your email still looks good when the custom font doesn't load.
- **Limit font weights** — Only include the weights you actually need (e.g., `wght@400;700`). Extra weights increase load time for recipients.
- **Stick to 1-2 custom fonts** — Too many fonts slow down rendering and make the design inconsistent. One for headings and one for body text is a common pattern.
- **Test across clients** — Gmail, Outlook, and Apple Mail all handle fonts differently. Gmail supports Google Fonts in most cases, but Outlook falls back to system fonts.

---

# Internationalization

Source: https://docs.templatical.com/guide/i18n

The editor UI supports locale switching. Every label, tooltip, placeholder and message is driven by a translation key — no hardcoded strings.

Two languages are in play, and they are not the same thing. `init({ locale })` sets the language of the **editor's own interface**. A template's `settings.locale` — the "Content language" field in Template Settings — sets the language of the **email being written**, and becomes `<html lang>` in the rendered output. A German-speaking marketer writing an English campaign sets the first to `de` and the second to `en`.

Writing direction is a third, independent setting. `settings.direction` (`"ltr"` or `"rtl"`) is the canvas `dir` and the rendered `<mjml dir>`. When unset, it follows the content language (`ar`, `he`, `fa`, `ur`, … → RTL). It does not follow `init({ locale })` or the host page. The "Right-to-left" toggle in Template Settings writes an explicit value; after that, locale no longer drives it.

## Setting the locale

Pass the `locale` option to `init()`:

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  locale: 'de',
});
```

## Built-in locales

| Code | Language |
|---|---|
| `en` | English (default) |
| `de` | German |
| `pt-BR` | Portuguese (Brazil) |
| `es` | Spanish |
| `ca` | Catalan |
| `fr` | French |
| `nl` | Dutch |
| `ja` | Japanese |

## Locale resolution

The editor normalizes locale codes by stripping region suffixes:

| Input | Resolved |
|---|---|
| `'en'` | `en` |
| `'en-US'` | `en` |
| `'en-GB'` | `en` |
| `'de-AT'` | `de` |
| `'fr-BE'` | `fr` |
| `'it'` | `en` (unsupported, falls back to English) |

If the resolved locale is not supported, the editor falls back to English and warns once on the console, naming the locales that would have worked. The fallback is silent only for the cloud chunk, which ships fewer locales on purpose (see below).

## Async loading

Locale files are loaded asynchronously using dynamic `import()`. Only the active locale is bundled into the client — the other locale files are not included in your build. This means switching locales at runtime requires re-initializing the editor:

```ts
async function switchLocale(newLocale: string) {
  editor.unmount();
  editor = await init({
    container: '#editor',
    locale: newLocale,
  });
}
```

## Translation loading

Translations are nested objects organized by UI section:

```ts
{
  blocks: {
    paragraph: 'Paragraph',
    image: 'Image',
    button: 'Button',
    // ...
  },
  toolbar: {
    duplicate: 'Duplicate',
    delete: 'Delete',
    // ...
  },
  blockSettings: {
    spacing: 'Spacing',
    padding: 'Padding',
    // ...
  },
  templateSettings: {
    layout: 'Layout',
    // ...
  },
}
```

Some strings support placeholder interpolation using `{placeholder}` syntax:

```ts
{
  header: {
    templatesUsed: '{used}/{max} templates used',
  },
}
```

## Default block text

New blocks start with placeholder text, and it follows the locale — a Button
dragged in under `locale: 'de'` reads "Hier klicken", not "Click Here".

Which locale depends on who the text is for:

| Default | Follows | Why |
|---|---|---|
| Title, Paragraph, Button text | `init({ locale })` | Author-facing prompts. They exist to be overwritten, so they match the interface around them. |
| Video `alt`, Countdown unit labels and expired message | the template's `settings.locale` | This text ships in the delivered email, so it follows the email's language — not the language of whoever is editing. |

Override any of it with [`blockDefaults`](/guide/defaults), which wins over both:

```ts
const editor = await init({
  container: '#editor',
  locale: 'de',
  blockDefaults: {
    // Your own wording, in any language you like.
    button: { text: 'Jetzt kaufen' },
    paragraph: { content: '<p>Text hier eingeben</p>' },
  },
});
```

The merge is deep, so overriding one field leaves the rest localized —
`button: { backgroundColor: '#ff6600' }` keeps the translated label.

::: tip
An unset locale behaves exactly as before this existed: the English defaults are
byte-identical to the factory values.
:::

## Dates

Relative labels ("5m ago") come from translation keys. Absolute dates and times —
version history entries, saved-block tooltips, the template's write-time line —
are built by `Intl` from `init({ locale })`, so they read in the same language as
the chrome beside them rather than in the browser's language.

The canvas also carries the template's `settings.locale` as a `lang` attribute,
so the browser's spellchecker and hyphenation judge the copy by the rules of the
language it is actually written in. It binds `dir` from `settings.direction`
(resolved as above) so an RTL host page cannot leak into an LTR email, and an
LTR host cannot keep Arabic copy LTR.

## Contributing a new locale

To add a new language:

1. Copy `packages/editor/src/i18n/locales/en.ts` to a new file named after your locale code (e.g. `<locale>.ts`)
2. Translate all string values, keeping the same key structure, and annotate the object with `typeof en` (see the example below)
3. Run `pnpm run typecheck` — the `typeof en` annotation makes missing, extra, or misnested keys fail at compile time
4. Run `pnpm run test` to verify placeholder parity — tests check that every `{placeholder}` token in the English strings also appears in your translations

There is no registration step. The supported-locale list is derived from the files in `locales/` at build time, so dropping in `<locale>.ts` is all it takes — `init({ locale: '<locale>' })` picks it up automatically.

Example structure for a new locale:

```ts
// packages/editor/src/i18n/locales/<locale>.ts
import type en from './en';

const translations: typeof en = {
  blocks: {
    paragraph: '…',
    image: '…',
    button: '…',
    section: '…',
    divider: '…',
    spacer: '…',
    // ... all keys from en.ts
  },
  toolbar: {
    duplicate: '…',
    delete: '…',
    // ...
  },
  // ... all sections from en.ts
};

export default translations;
```

### Cloud strings are optional

Translations are split into two chunks. The OSS chunk (`locales/*.ts`) covers everything in the open-source editor. A separate cloud chunk (`locales/cloud/*.ts`) covers features only available via `initCloud()` — AI, collaboration, template scoring, the lint save gate, and plan limits. You don't need to translate the cloud chunk: if `locales/cloud/<locale>.ts` doesn't exist, cloud features fall back to English while the rest of the editor renders in your language.

Submit a pull request with your translation file. Contributions for any language are welcome.

---

# Images

Source: https://docs.templatical.com/guide/images

When a user adds an image block, the editor shows a text field where they can paste an image URL. The image block toolbar exposes fields for `src`, `alt`, `width`, `align`, and an optional `linkUrl`.

<img src="/images/image-fields.png" alt="Image block fields" style="max-width: 360px;" />

## Browse and pick

A browse button appears alongside the URL input when **either** a [`media`](/backend/media) provider **or** `onRequestMedia` is configured.

<img src="/images/image-picker.png" alt="Media picker button" style="max-width: 360px;" />

`media` opens the built-in library modal. `onRequestMedia` is a **UI override** (Bynder, Cloudinary widget, a host modal): the editor calls it instead of opening the library, and it wins when both are set. Return a `MediaResult`, or `null` if the user cancels. When `alt` is provided, the editor fills in the image's alt text.

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  async onRequestMedia() {
    // Open your own modal, file browser, or asset manager
    const image = await openMyMediaModal();
    if (!image) return null;

    return { url: image.url, alt: image.alt };
  },
});
```

A gallery of your own is the `media` key — see [Media](/backend/media).

The type signature:

```ts
interface MediaResult {
  url: string;
  alt?: string;
}

interface MediaRequestContext {
  /** Media categories the editor is asking for (e.g. `['images']`). */
  accept?: MediaCategory[];
  /**
   * Files dragged directly onto an image block or field. Present only for
   * drag-and-drop requests; absent when the user clicks "Browse Media".
   */
  files?: File[];
}

type OnRequestMedia = (context?: MediaRequestContext) => Promise<MediaResult | null>;
```

## Drag and drop to upload

Users can drag an image file from their computer straight onto an image block (empty or filled), the sidebar image field, or a custom block's image field.

With `onRequestMedia`, the editor calls that handler with the dropped file in `context.files`:

```ts
const editor = await init({
  container: '#editor',
  async onRequestMedia(context) {
    // A file was dropped — upload it and return its hosted URL.
    if (context?.files?.length) {
      const url = await uploadToMyBackend(context.files[0]);
      return { url };
    }

    // No file — the user clicked "Browse Media". Open your picker.
    const image = await openMyMediaModal();
    return image ? { url: image.url, alt: image.alt } : null;
  },
});
```

With a `media` provider and no callback, drop goes to `provider.create({ file, templateId? })` when `create` is a function. `create: false` hides the drop affordance: the library is still browsable.

A few notes:

- **One file per drop.** `files` is an array for forward-compatibility, but the editor currently sends a single file (`files[0]`).
- **Images only.** The editor pre-filters dropped files to image MIME types before calling you.
- **No picker, no drop.** Without `onRequestMedia` and without a `media` provider whose `create` is a function, the drop affordance doesn't appear and drops are ignored.
- **Don't return a `blob:` URL.** `URL.createObjectURL(file)` is session-local and breaks export. Upload the file and return a durable URL (or a `data:` URL).

With a `media` provider whose `create` is a function, dropped files upload through that provider — no `onRequestMedia` needed. A custom handler still takes precedence.

## Display-only URL resolution

Some integrations store canonical image references that aren't directly displayable — for example, an offline-capable app whose templates reference images by plain file name (`logo.png`), displayable only via ephemeral `blob:` URLs created from local storage. `onRequestMedia` covers the media-browser path, but when a user types or pastes such a value into the `src` input, the canvas has nothing to show.

The `resolveImageUrl` callback closes that gap. It maps a src value to a **preview URL for the canvas only** — the content model keeps the canonical value, and `toMjml()` exports it untouched:

```ts
const editor = await init({
  container: '#editor',
  async resolveImageUrl(src) {
    const file = await myFileStore.lookup(src);
    return file ? URL.createObjectURL(file) : null;
  },
});
```

The type signature:

```ts
resolveImageUrl?: (src: string) => string | null | Promise<string | null>;
```

Return the preview URL, or `null` (or the input value) to use the src as-is.

How the editor calls it:

- **Once per committed value.** Typing in the src input is debounced, so partial values (`lo`, `logo.p`, …) never reach your resolver.
- **Cached per src** for the editor instance's lifetime — the same src in several blocks resolves once.
- **Failures fall back gracefully.** A thrown error or rejected promise is cached as "use as-is"; the editor won't retry the same src. Note this also holds for *transient* failures: a src that failed to resolve stays unresolved until the editor is re-initialized. A hook to re-trigger resolution may be added in a future release.
- **Merge-tag srcs are skipped.** A src like <code v-pre>{{product.image}}</code> is never passed to the resolver; its `placeholderUrl` (if set) is resolved instead.
- **Video thumbnails are covered too.** An explicit video `thumbnailUrl` (and a video block's `placeholderUrl`) resolves the same way. Thumbnails auto-derived from a YouTube/Vimeo URL are already real URLs and are never passed to the resolver.
- **Display-only, by design.** Unlike returning a `blob:` URL from `onRequestMedia` (which would end up in the export — see above), a URL from `resolveImageUrl` never enters the template content.

## Image Block Properties

The `ImageBlock` type defines all configurable properties:

| Property | Type | Description |
|---|---|---|
| `src` | `string` | Image source URL |
| `alt` | `string` | Alt text for accessibility |
| `width` | `number \| 'full'` | Image width in pixels, or `'full'` for 100% |
| `height` | `number` (optional) | Image height in pixels. Absent derives it from the width |
| `align` | `'left' \| 'center' \| 'right'` | Horizontal alignment |
| `borderRadius` | `number` (optional) | Corner radius in pixels. Absent or 0 means square corners |
| `decorative` | `boolean` (optional) | Hides the image from screen readers and sends an empty `alt` |
| `linkUrl` | `string` (optional) | Wraps the image in a link |
| `linkOpenInNewTab` | `boolean` (optional) | Opens the link in a new tab |
| `placeholderUrl` | `string` (optional) | Design-time preview image when `src` uses a merge tag |

### Height

`height` is optional, and leaving it out is usually right: the height is then derived from the width and the image keeps its aspect ratio. Set it when the layout needs a fixed box -- a banner slot of a known size, or a row of images that must line up.

Setting both `width` and `height` **stretches** the image to that box. It does not crop, because `object-fit` is unsupported in Outlook and most email clients, so the editor canvas stretches identically rather than promising a crop the inbox won't deliver. Match the ratio of the source image, or resize the asset before uploading it.

### Corner radius

`borderRadius` rounds the image's corners, in pixels. Omitted or `0` leaves them square, which is what every existing block gets.

To render a **circular** avatar or portrait, give the block a square source image and a radius of at least half its rendered size: a 240px square needs `120`, and any larger value (`999` is the usual shorthand) resolves to the same circle. A non-square image rounds to an ellipse, since there is no cropping step -- see Height above for why.

Support is good but not universal. Apple Mail, iOS Mail, Gmail and Outlook.com honour it; Outlook on Windows ignores it and shows square corners. Treat it as a progressive enhancement rather than something a layout depends on.

### Placeholder URL

When the `src` field contains a merge tag (e.g., <code v-pre>{{product.image}}</code>), the actual image won't render in the editor. Use `placeholderUrl` to provide a placeholder image in the editor. This value is not included in the exported output.

## Best practices

- **Use absolute URLs** -- Relative paths won't resolve in email clients. Always use `https://` URLs.
- **Prefer PNG or JPG** -- SVG and WebP have limited email client support. Use PNG for graphics with transparency, JPG for photos.
- **Keep file sizes reasonable** -- Large images slow down loading for recipients.
- **Always set alt text** -- Many email clients (especially Outlook) block images by default. Recipients see the alt text until they choose to load images.
- **Set explicit width** -- Email clients may render images at their native size if no width is specified, breaking your layout on small screens.
- **Leave the height out unless you need it** -- Width alone keeps the aspect ratio. A height that doesn't match the source ratio stretches the image in every client.

---

# Layout

Source: https://docs.templatical.com/guide/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`.

<!-- prettier-ignore -->
| Surface | With `layout` |
| --- | --- |
| `getContent()` / `setContent()` / load / save / `onChange` / history | authored template only |
| Editing canvas | authored template only |
| Section toolbar Wrapper panel | [In the editor](#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.

::: tip 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-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                    ← 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 })
```

<!-- prettier-ignore -->
| 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.

<!-- prettier-ignore -->
| 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.

---

# Logic Tags

Source: https://docs.templatical.com/guide/logic-tags

Logic tags are the control-flow tokens of your template syntax — conditionals and loops like <code v-pre>{% if vip %}</code> … <code v-pre>{% endif %}</code> or <code v-pre>{% for item in items %}</code> … <code v-pre>{% endfor %}</code>. They are a **separate concern from [merge tags](/guide/merge-tags)**: merge tags are data placeholders you pick from a list, logic tags are structure your sending platform evaluates at send time.

Because they're evaluated at send time, the editor shows them as badges rather than taking the branch. To see branches **resolved in a preview**, wire the `resolvePreview` hook — see [Preview Rendering](/guide/preview-rendering). It is the only way to resolve logic: sample values can substitute a value, but they cannot take a branch.

There are two independent parts:

- **Highlighting** — any logic tag you **type or paste** into a title or paragraph is detected from the syntax preset's `logic` pattern and rendered as an uppercase keyword badge (**IF**, **ENDIF**, **FOR**…). This is always on and needs no configuration. See [Merge Tags → Logic tag highlighting](/guide/merge-tags#logic-tag-highlighting) for how detection and the syntax presets work.
- **Insertion** — configure a `logicTags` option to give authors a dedicated **Logic** button, so they can drop in control flow without writing the syntax by hand. That's what this page covers.

## Configuration

Pass a `logicTags` option with `tags` (standalone tokens) and/or `pairs` (open/close constructs). Both are optional.

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  logicTags: {
    tags: [
      { label: 'Else', value: '{% else %}', group: 'Conditions' },
    ],
    pairs: [
      {
        label: 'If VIP',
        before: '{% if customer.vip %}',
        after: '{% endif %}',
        group: 'Conditions',
        description: 'Show the wrapped content only to VIP customers',
      },
      {
        label: 'Loop items',
        before: '{% for item in order.items %}',
        after: '{% endfor %}',
        group: 'Loops',
      },
    ],
  },
});
```

Logic tags use the same `mergeTags.syntax` preset (the preset defines both the data and logic patterns), so you don't configure syntax twice.

## Types

```ts
interface LogicTag {
  label: string;
  value: string;        // a standalone logic token, e.g. '{% else %}'
  group?: string;       // optional section label in the picker
  description?: string; // optional helper text in the picker
}

interface LogicPair {
  label: string;
  before: string;       // opening tag, e.g. '{% if vip %}'
  after: string;        // closing tag, e.g. '{% endif %}'
  group?: string;       // optional section label in the picker
  description?: string;
}

interface LogicTagsConfig {
  tags?: LogicTag[];
  pairs?: LogicPair[];
  onRequest?: () => Promise<LogicTag | LogicPair | null>;
}
```

`value` / `before` / `after` must include the syntax delimiters. `group` and `description` are picker-only — they never appear on the canvas or in the rendered MJML.

## The picker

When `logicTags` is configured, a **Logic** button appears in the title and paragraph toolbars (next to **Merge tag**). Clicking it opens a picker:

![The Logic and Merge tag buttons in the rich-text toolbar](/images/logic-tags-picker-rich-text.png)

- `tags` and `pairs` share one list, grouped by `group` — a section lists its open/close pairs first, then its standalone tags, so a group used by both (e.g. "Conditions") appears once.
- A standalone tag shows a single keyword badge (e.g. **ELSE**); a pair shows both (e.g. **IF … ENDIF**), signalling that it wraps your selection.

Type to filter; use `↑`/`↓` then `Enter` to insert; `Esc` or the backdrop dismisses.

![The Insert logic picker — the "Conditions" group lists the "If VIP" pair first, then the "Else" tag, each with keyword badges](/images/logic-tags-selection-2.png)

## Insertion behavior

- A **standalone tag** is inserted at the cursor.
- A **pair** wraps the current selection — `[open]{selection}[close]` — so authors can select a paragraph and make it conditional in one step.
- A **pair with no selection** drops both pills adjacent, with the caret placed between them so you can type the wrapped content.

For example, select the text you want to make conditional:

![A paragraph block with the phrase "You your VIP ticket now!" selected](/images/logic-tags-selection-1.png)

Choose a pair (here, **If VIP**) and it wraps the selection — the text now sits between the opening and closing pills:

![The same paragraph with the selection wrapped in IF … ENDIF logic pills](/images/logic-tags-selection-3.png)

Both sides render as the same styled logic badges as typed logic, and pass through unchanged in the rendered MJML. The editor does not enforce balancing, so offer the matching opener/closer as a single `pair` (recommended) rather than two separate `tags`.

## Your own picker

Set `logicTags.onRequest` to replace the built-in picker with your own UI — a custom modal, a conditional builder, or logic constructs fetched from an API (and it's how consumers with a non-preset syntax wire their own experience). It's called when the user clicks **Logic**; return the chosen `LogicTag` / `LogicPair`, or `null` to cancel. Precedence mirrors merge tags: **`onRequest` → built-in picker → nothing**.

```ts
const editor = await init({
  container: '#editor',
  logicTags: {
    onRequest: async () => {
      const choice = await showMyLogicPicker();
      return choice; // LogicTag | LogicPair | null
    },
  },
});
```

## Beyond rich text: plain fields

Logic isn't limited to title/paragraph blocks. Inputs and textareas that already support merge tags — button text, button/image URLs, alt text — also get the **Logic** button and highlight logic tags in their value.

![A Button block's Text field with the Merge tag and Logic buttons beneath it](/images/logic-tags-picker-input.png)

For example, make button text conditional:

`{% if guest.status == 'vip' %}VIP access{% else %}Sign up{% endif %}`

In a plain field a tag inserts at the cursor and a pair wraps the selected text (string-wise), the same as in rich text.

---

# Merge Tags

Source: https://docs.templatical.com/guide/merge-tags

Merge tags are tokens for dynamic content -- things like a recipient's name, a product price, or an unsubscribe URL. They appear as highlighted tokens in the editor and pass through unchanged in the rendered MJML. Your email sending platform replaces them with real values at send time.

Templatical provides built-in syntax presets for popular platforms and supports custom syntax definitions.

## Configuration

Pass a `tags` array to register your merge tags with the editor. When the editor detects a merge tag value in the content (e.g. <code v-pre>{{first_name}}</code>), it replaces it visually with the human-readable `label` ("First Name") — making the template much easier to read and edit. The raw value is preserved in the output.

![Data tag displayed in the editor](/images/data-tag.png)

Hovering over a tag reveals the raw value behind the label.

The `syntax` property is optional and defaults to `'liquid'`.

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  mergeTags: {
    tags: [
      { label: 'First Name', value: '{{first_name}}' },
      { label: 'Last Name', value: '{{last_name}}' },
      { label: 'Email', value: '{{email}}' },
      { label: 'Company', value: '{{company.name}}' },
      { label: 'Unsubscribe URL', value: '{{unsubscribe_url}}' },
    ],
  },
});
```

## MergeTag type

Each tag is defined with a label (shown in the editor UI) and a value (the full merge tag string including delimiters). Two optional fields — `group` and `description` — are used by the built-in picker to organize and explain tags:

```ts
interface MergeTag {
  label: string;
  value: string;
  group?: string;        // optional grouping shown in the picker
  description?: string;  // optional helper text shown in the picker
  sample?: string;       // optional example value shown in previews
}
```

The `value` must include the syntax delimiters. For example, with Liquid syntax:

<code v-pre>value: '{{first_name}}'</code>

The `group` and `description` fields are picker-only — they do not appear in the editor canvas, in autocomplete, or in the rendered MJML output. They are ignored if you only use `onRequest` for tag selection.

## Sample values

A tag can carry a `sample` — an example value that **preview surfaces** render in its place, so a preview reads like a delivered email instead of a list of field names:

```ts
mergeTags: {
  tags: [
    { label: 'First Name', value: '{{first_name}}', sample: 'Ada' },
    { label: 'Plan', value: '{{plan_name}}', sample: 'Pro' },
  ],
}
```

Setting `sample` is the whole opt-in — there is no flag to enable alongside it. The value never leaves the preview: it is not written to the template, not returned by `getContent()`, not sent, and not present in MJML output. It also shows in the built-in picker, so an author can see what a tag will render before inserting it.

**How previews use it — the Sample / Label switch, which tags keep their highlight, and what happens on the editing canvas — is covered in [Preview Rendering](/guide/preview-rendering).** That page also documents `resolvePreview`, the hook for having your own backend resolve a preview, which is the only way to evaluate logic tags.

## Syntax presets

Templatical includes four built-in syntax presets. The `syntax` setting tells the editor how to detect and highlight both data tags and logic tags in content.

Each preset defines two patterns:
- **Data tags** -- variable merge tags like a recipient's name or email
- **Logic tags** -- control flow statements like conditionals and loops

| Preset | Data tag | Logic tag | Platform |
|--------|----------|-----------|----------|
| `'liquid'` | <code v-pre>{{first_name}}</code> | `{% if vip %}` | Shopify, Jekyll, Django, Jinja2 |
| `'handlebars'` | <code v-pre>{{first_name}}</code> | <code v-pre>{{#if vip}}</code> | Handlebars.js, Mandrill |
| `'mailchimp'` | `*\|FIRST_NAME\|*` | `*\|IF:VIP\|*` | Mailchimp |
| `'ampscript'` | `%%=first_name=%%` | `%%[IF @vip]%%` | Salesforce Marketing Cloud |

```ts
mergeTags: {
  syntax: 'handlebars',
  tags: [
    { label: 'First Name', value: '{{first_name}}' },
  ],
}
```

## Logic tag highlighting

Beyond data tags, the editor also recognizes logic tags -- conditional statements, loops, and other control flow syntax used by your email platform. These are detected automatically using the `logic` regex pattern from the selected syntax preset.

When a logic tag is detected in content, the editor extracts the keyword (the first capture group from the logic regex) and displays it as an uppercase badge -- for example, `{% if customer.vip %}` renders as **IF** and `{% endif %}` renders as **ENDIF**. Hovering over the badge shows the full tag value as a tooltip. Users can click the badge to edit the raw value.

![Logic tag displayed in the editor](/images/logic-tags-selection-3.png)

Logic tags are styled differently from data tags (outlined badge with primary color vs filled background) so template authors can distinguish between data tags and control flow at a glance.

Like data tags, logic tags pass through unchanged in the rendered MJML — your sending platform evaluates them at send time.

::: tip Inserting logic tags
This section covers **highlighting** — any logic tag you type or paste is detected automatically. To let users **insert** logic tags without typing them (a dedicated **Logic** button, condition/loop blocks that wrap a selection), see the separate [Logic Tags](/guide/logic-tags) guide. Logic is configured independently of merge tags.
:::

Examples of logic tags by preset:

::: code-group
```html [Liquid]
{% if customer.vip %}
  <p>Exclusive offer just for you!</p>
{% endif %}

{% for item in cart.items %}
  <p>{{item.name}} - {{item.price}}</p>
{% endfor %}
```
```html [Handlebars]
{{#if hasSubscription}}
  <p>Your plan renews on {{renewal_date}}</p>
{{/if}}

{{#each products}}
  <p>{{this.name}}</p>
{{/each}}
```
```html [Mailchimp]
*|IF:VIP|*
  <p>VIP discount applied</p>
*|END:IF|*
```
```html [AMPscript]
%%[IF @subscriber_type == "premium"]%%
  <p>Premium content here</p>
%%[ENDIF]%%
```
:::

## Custom syntax

If the built-in presets don't match your platform, define a custom syntax with two regex patterns -- one for data tags and one for logic tags:

```ts
interface SyntaxPreset {
  value: RegExp;  // matches data tags like ${user.name}
  logic: RegExp;  // matches logic tags like $[IF ...]
}
```

Example for a `${...}` / `$[...]` syntax:

```ts
mergeTags: {
  syntax: {
    value: /\$\{.+?\}/g,
    logic: /\$\[\s*(\w+).*?\]/g,
  },
  tags: [
    { label: 'User Name', value: '${user.name}' },
    { label: 'Order Total', value: '${order.total}' },
  ],
}
```

The `value` regex detects data tags. The `logic` regex detects control flow statements — the first capture group `(\w+)` extracts the keyword (e.g., `IF`, `FOR`) which the editor uses as the display label.

## Autocomplete

When users type the syntax opener (e.g. <code v-pre>{{</code> for Liquid/Handlebars, `*|` for Mailchimp, `%%=` for AMPscript), the editor surfaces a popup listing matching tags from the configured `tags` array. Selecting an item (mouse click, `Enter`, or `Tab`) inserts it as a merge tag — the same form produced by the toolbar picker. `Esc` or clicking elsewhere dismisses the popup.

Autocomplete works both inside title/paragraph rich-text blocks **and** in every merge-tag-enabled input and textarea field (button and image URLs, image alt text, video and menu links, the rich-text link dialog's URL field, template settings, and custom-block text fields). The popup, filtering, keyboard navigation, and positioning are identical across both surfaces.

Filtering is case-insensitive and matches against both `label` and `value`. The list is capped at 10 results.

Autocomplete is enabled by default. It is **automatically disabled** when:

- `tags` is empty (no candidates to suggest), or
- `syntax` is a custom regex (the editor cannot infer a trigger string from arbitrary regexes).

To opt out explicitly, set `autocomplete: false`:

```ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    autocomplete: false,
    tags: [
      { label: 'First Name', value: '{{first_name}}' },
    ],
  },
});
```

The toolbar's **Merge tag** button continues to work regardless of the autocomplete setting.

## Built-in picker

When you configure `mergeTags.tags` without an `onRequest` callback, clicking the **Merge tag** button in the rich text toolbar (or next to a sidebar text input) opens a built-in modal picker. The picker lists every tag from `tags`, supports keyboard navigation, and offers a search field that matches against `label`, `value`, and `description`.

![Built-in merge tag picker](/images/merge-tag-picker.png)

The picker shows:

- the **label** (bold)
- the raw **value** (mono, dim)
- the optional **description** (small, dim) when set

When at least one tag carries a `group` field, the picker renders sectioned headers in insertion order (the order tags appear in your `tags` array). Tags without `group` fall under a localized "Other" header. When no tag has a `group`, the picker renders a plain flat list — no headers, no "Other" bucket.

Typing in the search field flattens groups and filters the list. Case-insensitive substring matches against the tag's `label`, `value`, or `description`. Clearing the search restores the grouped (or flat) layout.

Single-step insert: clicking a row, or pressing `Enter` on the highlighted row, inserts the tag and closes the modal. `Esc`, the header close (×), or clicking the backdrop all dismiss the picker without inserting.

```ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    tags: [
      {
        label: 'First Name',
        value: '{{first_name}}',
        group: 'Recipient',
        description: 'Personalized greeting',
      },
      {
        label: 'Last Name',
        value: '{{last_name}}',
        group: 'Recipient'
      },
      {
        label: 'Company',
        value: '{{company.name}}',
        group: 'Account'
      },
      {
        label: 'Unsubscribe URL',
        value: '{{unsubscribe_url}}',
        description: 'Required by anti-spam legislation',
      },
    ],
  },
});
```

## Dynamic tag loading

For large or context-dependent tag lists, use the `onRequest` callback instead of (or in addition to) a static `tags` array. The editor calls this function when the user clicks to insert a merge tag. Use it to open a custom picker modal, fetch available merge tags from your API, or build a context-aware tag list based on the current user. Return the selected `MergeTag` or `null` to cancel.

```ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    onRequest: async () => {
      const tag = await showMyMergeTagPicker();
      return tag; // MergeTag or null if cancelled
    },
  },
});
```

::: tip Precedence
If you provide both `tags` and `onRequest`, `onRequest` takes precedence — the **Merge tag** button always calls your callback. The static `tags` array still powers the typing-autocomplete suggestion list.
:::

## Changing a tag that is already in the content

Activating a tag in the content — clicking it, or pressing <kbd>Enter</kbd> on it — reopens the chooser so the author picks a different tag. It is the same chooser insertion uses, called with a `context` that says which job it is doing:

```ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    onRequest: async (context) => {
      // context: { reason: 'insert' | 'edit', current?: MergeTag }
      return showMyMergeTagPicker({ preselect: context?.current });
    },
  },
});
```

The parameter is optional, so a callback that ignores it keeps working unchanged.

Which route an activated tag takes depends on what can resolve its token:

| Configuration | Activating a tag opens |
|---|---|
| `onRequest` is set | your chooser, always — `context.current` is the resolved tag, or absent for a token that matches no entry in `tags` |
| only `tags` is set, and the token matches one | the built-in picker, with that tag preselected |
| the token matches nothing, or neither is configured | a text input holding the raw token |

The last row is the only place a token is editable as text, and it exists so a legacy or mistyped token can still be repaired. Input there is checked against your `syntax` — a value that is not a merge tag is never committed, because it would otherwise be written into the sent email verbatim.

## Hiding the raw token

A tag's tooltip shows the token behind its label. That suits a readable syntax like <code v-pre>{{first_name}}</code>, where the token tells an author which field they are looking at. When `value` is an internal identifier your backend resolves, set `showRawValue: false`:

```ts
const editor = await init({
  container: '#editor',
  mergeTags: {
    showRawValue: false,
    onRequest: async () => showMyFieldPicker(),
  },
});
```

Authors then see only labels — on the canvas, in sidebar fields and in the built-in picker. Display-only: the token is unchanged in stored content and in the rendered output.

It also governs what a tag *renders as*, not only its tooltip. A tag resolves its display label in this order:

1. the `label` of the matching entry in `tags`;
2. the label stored on the tag when it was inserted or last changed;
3. the token itself — or, with `showRawValue: false`, a neutral placeholder.

Step 2 is what keeps a tag your `onRequest` **minted** readable: it is in no `tags` array, so only the label you returned identifies it. For a tag the editor made itself — typed, pasted, or converted from loaded content — the stored label is the token, so the two steps agree and nothing changes.

::: tip Sidebar fields
A field value is a single string that mixes text and tokens (<code v-pre>Hi {{first_name}}, welcome</code>). Its tags are individually clickable and re-picked like any other. To change the text around them, use **Edit as text** — or click anywhere else in the field — which opens the whole string, tokens included. `showRawValue` does not change that.
:::

## Changing the tag list after `init()`

`editor.setMergeTags(tags)` replaces the configured list at runtime:

```ts
const editor = await init({
  container: '#editor',
  mergeTags: { tags: initialTags },
});

// A tag was renamed, or your picker minted a new one.
editor.setMergeTags(nextTags);
```

Everything that renders a tag reads the same list, so the canvas, the sidebar fields and the built-in picker repaint together. It is available on `initCloud()` too.

::: warning Mutating the array does not work
Pushing into the array you passed to `init()` is not supported. It never repaints anything already on screen — only tags rendered *after* the push pick up the change. Call `setMergeTags` instead.
:::

::: tip Autocomplete has one limit
Whether type-ahead is active is decided when a block opens for editing. Going from no tags to some enables it for the next block opened, not for one already being edited. Everything else — labels, the **Insert merge tag** control, the picker — updates immediately.
:::

## Tokens in loaded content

Content that never passed through the editor — a template from your own store, or one produced by the [`@templatical/import-*`](/guide/migration-from-html) converters — carries merge tags as bare <code v-pre>{{tokens}}</code> rather than as tag nodes. The editor converts them on the way in, so a loaded tag behaves exactly like a typed one: human label, highlight, `sample`, and selectable as a single unit.

There is nothing to call and nothing to enable. It runs wherever content arrives:

| Path | When |
|---|---|
| `init({ content })` / `initCloud({ content })` | before mount |
| `editor.setContent(content)` | before the content reaches the canvas |
| `editor.create({ content })` | before the content becomes editor state |
| `editor.load(id)` | as the [`templates`](/backend/templates) provider's result returns |
| [Version history](/backend/version-history) preview and restore | as each version reaches the canvas |

Matching follows your configured `syntax`, not the `tags` array, so an undeclared token still becomes a tag — labelled with its own raw value.

**Only text is converted.** A token in an `href`, `src` or any other attribute is left byte-identical:

```html
<!-- in -->
<p>Hi {{first_name}} — <a href="{{unsubscribe_url}}">unsubscribe</a></p>

<!-- out -->
<p>Hi <span data-merge-tag="{{first_name}}">First Name</span> —
   <a href="{{unsubscribe_url}}">unsubscribe</a></p>
```

**Only rich text is converted** — `TitleBlock.content` and `ParagraphBlock.content`. Every other merge-tag-bearing field is rendered as text and keeps its bare tokens: button text and URLs, image `src`/`alt`, `HtmlBlock.content`, custom-block field values, `settings.preheaderText`, and table cells.

::: warning `getContent()` is not a byte-for-byte round-trip
A loaded template's bare tokens come back as tag nodes. Nothing is written to your store unless you save, and nothing is marked as an unsaved change — but expect a one-time difference if you diff or checksum stored templates.

Output is unaffected: `toMjml()` / `toHtml()` replace a tag node with its token, so a converted template and its bare-token original compile identically.
:::

::: tip Writing a `resolvePreview` hook
Your [`resolvePreview`](/guide/preview-rendering) callback receives tag nodes, including for content that arrived as bare tokens. Match on the tag markup, not the raw token — a naive <code v-pre>replaceAll('{{first_name}}', 'Grace')</code> also hits the token inside <code v-pre>data-merge-tag="{{first_name}}"</code> and silently produces a tag that renders its label.
:::

## Merge tags in other inputs

Merge tags aren't limited to title and paragraph blocks. The editor detects and highlights merge tags in other block inputs too — button text, button URL, image URL, image alt text, and link href values. The same label replacement and tooltip behavior applies in these fields.

<img src="/images/button-merge-tag.png" alt="Merge tag in a button URL" style="max-width: 360px;" />

### Link URLs

The **Insert Link** dialog in a title or paragraph block takes merge tags in its URL field, through the same insert button, picker and autocomplete as every other URL field. A link is often per-recipient or per-event, so its URL is frequently a tag rather than a literal.

A URL you type without a scheme is completed with `https://` — except when it opens with a merge tag, which is stored exactly as written:

```text
you type                         stored href
──────────────────────────────   ──────────────────────────────
{{event_url}}                 →  {{event_url}}
{{base_url}}/events/42        →  {{base_url}}/events/42
https://acme.com/{{event_id}} →  https://acme.com/{{event_id}}
acme.com/promo                →  https://acme.com/promo
```

The tag supplies its own scheme, so prefixing one would produce `https://https://…`. The scheme allowlist still runs first, so a tag cannot smuggle a rejected scheme (`javascript:`, `data:`) past it.

A tag in an `href` is left byte-identical on export — see [Tokens in loaded content](#tokens-in-loaded-content) — so the sending system resolves it, exactly as it does in a button URL.

## Using merge tags outside the editor

The editor handles merge tags on every surface it owns — rich-text blocks, the toolbar picker, and the other block inputs above. For inputs **outside** the editor, such as an email subject field in your own app, build a small field of your own using the merge-tag primitives that `@templatical/types` exports. These are the same functions the editor uses internally, so your field stays consistent with whatever `syntax` you configured the editor with.

The package (MIT) exports the full toolkit:

- `SYNTAX_PRESETS` — the built-in syntax definitions (`liquid`, `handlebars`, `mailchimp`, `ampscript`)
- `getSyntaxTriggerChar` / `getSyntaxClosingChar` — a preset's opening/closing delimiters, for autocomplete detection
- `isMergeTagValue`, `getMergeTagLabel`, `containsMergeTag` — matching and label resolution
- `isLogicMergeTagValue`, `getLogicMergeTagKeyword` — the same for logic tags
- the `MergeTag` and `SyntaxPreset` types

### Render a stored value as labeled chips

Split a raw string into plain text and resolved tag labels — essentially the editor's own segmentation:

```ts
import { SYNTAX_PRESETS, getMergeTagLabel, type MergeTag } from '@templatical/types';

const syntax = SYNTAX_PRESETS.liquid;
const tags: MergeTag[] = [{ label: 'First name', value: '{{first_name}}' }];

function segments(value: string) {
  const re = new RegExp(syntax.value.source, 'g');
  const out: { text: string; isTag: boolean; label?: string }[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  while ((m = re.exec(value))) {
    if (m.index > last) out.push({ text: value.slice(last, m.index), isTag: false });
    out.push({ text: m[0], isTag: true, label: getMergeTagLabel(m[0], tags) });
    last = m.index + m[0].length;
  }
  if (last < value.length) out.push({ text: value.slice(last), isTag: false });
  return out;
}

// segments('Hi {{first_name}}!') →
//   [ { text: 'Hi ', isTag: false },
//     { text: '{{first_name}}', isTag: true, label: 'First name' },
//     { text: '!', isTag: false } ]
```

### Autocomplete on a plain input

The delimiter helpers keep your own dropdown syntax-accurate across every preset:

```ts
import { SYNTAX_PRESETS, getSyntaxTriggerChar, getSyntaxClosingChar } from '@templatical/types';

const syntax = SYNTAX_PRESETS.liquid;
const open = getSyntaxTriggerChar(syntax);   // '{{'
const close = getSyntaxClosingChar(syntax);  // '}}'

// On each keystroke, look at the text before the caret: if it contains an
// unclosed `open` delimiter, take the fragment after it as the query and
// filter your tags into a dropdown of your own.
```

Owning the field means it renders in your framework, styled to your design system, against your own tag model — which for something like a subject line is usually what you want.

---

# Migration from BeeFree

Source: https://docs.templatical.com/guide/migration-from-beefree

The `@templatical/import-beefree` package converts BeeFree (BEE) JSON templates into Templatical's `TemplateContent` format.

::: warning
This package is in active development. Some block types and advanced features may not be fully supported yet. Test your converted templates before using them in production.
:::

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-beefree
```

```bash [pnpm]
pnpm add @templatical/import-beefree
```

```bash [yarn]
yarn add @templatical/import-beefree
```

```bash [bun]
bun add @templatical/import-beefree
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertBeeFreeTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-beefree/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertBeeFreeTemplate } from '@templatical/import-beefree';

// Load your BeeFree template JSON
const res = await fetch('/api/beefree-templates/123');
const beefreeJson = await res.json();

// Convert to Templatical format
const { content, report } = convertBeeFreeTemplate(beefreeJson);

// Use in the editor
const editor = await init({
  container: '#editor',
  content,
});

// Check the conversion report for any issues
console.log(report);
```

The function returns an `ImportResult` with:
- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each block (`converted`, `approximated`, `html-fallback`, or `skipped`)

| Status | Meaning |
|---|---|
| `converted` | Mapped to a Templatical block with no loss of fidelity. |
| `approximated` | Mapped, with a clamp or flatten — `note` states what changed. |
| `html-fallback` | No block equivalent; original markup is an `HtmlBlock`. |
| `skipped` | No output (forms, and anything the converter refuses). |

The JSON BeeFree's editor persists (`page.rows`) is the input. A compiled HTML export is a different envelope — use [`@templatical/import-html`](/guide/migration-from-html).

## Block Mapping

BeeFree block types map to Templatical equivalents:

| BeeFree Module | Templatical Block | Status |
|---|---|---|
| Text | `paragraph` | Converted |
| Paragraph | `paragraph` | Converted |
| Heading | `title` | Converted |
| List | `paragraph` | Converted |
| Image | `image` | Converted |
| Button | `button` | Converted |
| Divider | `divider` | Converted |
| Spacer | `spacer` | Converted |
| Social | `social` | Converted |
| Html | `html` | Converted |
| Menu | `menu` | Approximated (styles may differ) |
| Video | `video` | Converted |
| Table | `table` | Converted |

Unknown module types are converted to HTML blocks as a fallback.

## Column Layout Conversion

BeeFree organizes content into rows with columns. These map to Templatical's `SectionBlock` with the appropriate `ColumnLayout`:

| BeeFree Columns | Templatical Layout |
|---|---|
| 1 column (100%) | `'1'` |
| 2 equal columns | `'2'` |
| 3 equal columns | `'3'` |
| 2 columns (~33/66) | `'1-2'` |
| 2 columns (~66/33) | `'2-1'` |

Column widths that don't match a standard ratio are mapped to the closest available layout.

## Template Settings

Global template settings are converted where possible:

- **Width** -- BeeFree `page.body.content.style.width` maps to `settings.width`
- **Background color** -- Row and body background colors are preserved
- **Font family** -- The default font family carries over to `settings.fontFamily`

## Known Limitations

- **Custom fonts** -- BeeFree custom font declarations are not automatically imported. Add them manually via the `fonts` config option.
- **Conditional display** -- BeeFree dynamic content rules do not have a direct equivalent and are dropped during conversion.
- **Icons** -- BeeFree custom icon uploads are not migrated. Standard social platform icons are mapped by name.
- **Forms** -- BeeFree form blocks have no Templatical equivalent and are skipped.
- **Advanced styling** -- Some granular BeeFree style properties (e.g., per-column padding overrides, content-area background images) may not be fully preserved.

## Verifying Converted Templates

After conversion, review the output in the editor to check for:

1. Missing images (re-upload or update URLs if needed)
2. Font rendering (add custom fonts to the editor config)
3. Column proportions (adjust layouts if the automatic mapping doesn't match)
4. Spacing and padding (fine-tune in the block settings panel)

---

# Migrating from Chamaileon

Source: https://docs.templatical.com/guide/migration-from-chamaileon

This guide is for teams who've built email templates in [Chamaileon](https://chamaileon.io) — in the hosted editor, or through a product that embeds the SDK — and want to move to Templatical's visual editor. **`@templatical/import-chamaileon`** converts a Chamaileon persist document into Templatical's `TemplateContent` format. Install it, run it, and use the sections below to finish off anything it can't map on its own.

The input is `editorInstance.methods.getDocument()`, not `getEmailHtml()` / the HTML generator.

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-chamaileon
```

```bash [pnpm]
pnpm add @templatical/import-chamaileon
```

```bash [yarn]
yarn add @templatical/import-chamaileon
```

```bash [bun]
bun add @templatical/import-chamaileon
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertChamaileonTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-chamaileon/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertChamaileonTemplate } from '@templatical/import-chamaileon';

const document = await editorInstance.methods.getDocument();

const { content, report } = convertChamaileonTemplate(document);

const editor = await init({
  container: '#editor',
  content,
});

console.log(report);
```

`convertChamaileonTemplate` is synchronous and returns an `ImportResult` with:

- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each produced block (`converted`, `approximated`, `html-fallback`, or `skipped`)

It also accepts the document serialized as a JSON string, for callers that store or transmit it that way.

::: tip
Pass the persist document from `getDocument()`. That object is `{ body, variables?, components?, title?, previewText?, subjectLine?, fontFiles? }` with `body.type === "body"`. The HTML generator (`getEmailHtml()`) is a different surface — compiled table markup that belongs to [`@templatical/import-html`](/guide/migration-from-html), not this package. Guessing between the two would send markup through the wrong converter.

Email JSON 2.0 through 4.1 is accepted after a key normalizer. Kebab-case styles (`background-color`) and camelCase (`backgroundColor`) are the same property, two versions. Inline `{ reference, default }` colour objects resolve to `default`, or to the matching `variables[]` entry when `default` is missing. The `reference` name is a design token, not a recipient field, so it is not stored as a merge tag.
:::

## Report

Each `report.entries` item describes one produced block:

| Status | Meaning |
|---|---|
| `converted` | Mapped to a Templatical block with no loss of fidelity. |
| `approximated` | Mapped to the right block, with a clamp or flatten — `note` states what changed. |
| `html-fallback` | No block equivalent exists; the raw node is preserved as JSON inside an `HtmlBlock`. |
| `skipped` | An empty loop or conditional — there were no children to convert. |

```ts
console.log(report.summary);
// { total: 24, converted: 18, approximated: 5, htmlFallback: 0, skipped: 1 }

for (const entry of report.entries) {
  if (entry.status === 'approximated') {
    console.warn(`${entry.sourceTag} approximated:`, entry.note);
  }
}
```

A `note` on an `approximated` entry names the original value. `report.warnings` carries document-level drops that do not attach to a single entry — a non-empty `subjectLine` or `fontFiles`, and a count of colour variables resolved to their defaults.

## Document shape

Chamaileon stores a design as a tree of typed nodes under `body`:

```
document
  body (type "body")
    children: fullwidth | block-level-loop | block-level-conditional
      fullwidth.children: box | multicolumn | leaf
        box.children: box | multicolumn | leaf
        multicolumn.children: column only
          column.children: box | multicolumn | leaf
```

Leaves in the documented element list: `text`, `typed-text`, `button`, `image`, `dynamic-image`, `divider`, `social`, `video`, `code`. `eid` is discarded — Templatical mints its own IDs. `placeholder` is editor chrome and is skipped. `customData` is ignored.

- **Chamaileon** stores this persist tree, plus optional `variables[]` (design tokens) and `fontFiles`.
- **Templatical** stores templates as a JSON tree of typed blocks (`SectionBlock`, `ParagraphBlock`, etc.) and renders that tree to MJML at export time.

`@templatical/import-chamaileon` walks `body.children`, flattens `box` and nested `multicolumn`, and constructs the equivalent Templatical blocks. The mapping table below is what it implements.

## Visual rebuild

For a handful of templates, rebuilding by hand next to a Chamaileon preview is often faster than installing a package:

1. Open the Chamaileon design — the editor itself, or a preview of `getEmailHtml()` — in one window.
2. Open the Templatical editor (or [the playground](https://play.templatical.com)) side-by-side.
3. Use that preview as your visual target. Do not feed `getEmailHtml()` into this converter.
4. Drag in the equivalent Templatical blocks (see the [mapping table](#chamaileon-node-mapping) below).
5. Copy text content directly. Re-host images via your media library.
6. Reproduce styling using Templatical's [design tokens](/guide/theming).

Most Chamaileon templates port in 10–20 minutes once you've done one or two. For a larger batch, run `@templatical/import-chamaileon` first and use this path only to finish off what it left `approximated`, `skipped`, or as an `html-fallback` block.

## Renderer verification

Once you have a template in Templatical — imported or rebuilt by hand:

```ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(content);
```

A visual comparison against Chamaileon's own preview catches what the report cannot. Several of the losses below — `hoverBackgroundColor`, per-side borders on leaves — are dropped at the site they are read, with a note when the converter has somewhere to attach one.

There is no round-trip oracle. Templatical does not render Chamaileon JSON, and this package does not consume `getEmailHtml()`.

## Node mapping {#chamaileon-node-mapping}

| Chamaileon | Templatical block | Notes |
|---|---|---|
| `body` | Template `settings` | `bodyWidth` → `settings.width`; `backgroundColor` → `settings.backgroundColor`; `previewText` → `settings.preheaderText` when non-empty. |
| `fullwidth` | `SectionBlock` | Outer `backgroundColor` → `wrapper.backgroundColor`; `contentBackgroundColor` → `section.styles.backgroundColor`; `contentPadding*` → padding. A fullwidth with no `multicolumn` is `columns: "1"`. |
| `box` | Flattened into the parent | Transparent boxes vanish with no entry. A painted box that is the sole child of a fullwidth whose content fill is unset copies that paint onto the section. A painted box among siblings flattens, `approximated`. |
| `multicolumn` + `column` | Section `columns` / `children[i]` | Pixel widths convert to percent-of-`bodyWidth` and match Templatical's five layouts. Nested `multicolumn` cannot become a nested `SectionBlock` — inner columns flatten into the parent column, `approximated`. |
| `text` | `TitleBlock` or `ParagraphBlock` | Inferred from `attrs.text` HTML: a sole heading wrapping the whole content becomes a title; anything else becomes a paragraph. |
| `typed-text` | `TitleBlock` or `ParagraphBlock` | `style.subType === "title"` → title; `"list"` → paragraph, `approximated`; otherwise paragraph. |
| `button` | `ButtonBlock` | `href` → `url`; label stripped of tags. An unset fill is an outlined button — fill becomes `#ffffff` (not the factory `#333333`) and the entry is `approximated`. |
| `image` | `ImageBlock` | `src` from `attrs` then `style`; `altText` → `alt`. |
| `dynamic-image` | `ImageBlock` | `approximated`; the note names the source type. |
| `divider` | `DividerBlock` | 2.0 `attrs.lineStyle` and 4.1 `style.width` / `type` / `color` (line thickness, not block width). |
| `social` | `SocialIconsBlock` | `elements[]` is the source of truth. Unknown platform names become `website`. Icon size snaps to 24 / 32 / 48 px. |
| `video` | `VideoBlock` | `link` → `url`; `src` → `thumbnailUrl`. A missing thumbnail stays the factory empty string. |
| `code` | `HtmlBlock` | From `attrs.html` / `attrs.code` / `attrs.content` (first set). |
| `block-level-loop`, `block-level-conditional`, `branch`, `loop`, `conditional` | Children, or skip | Empty → `skipped`. Non-empty → children convert as if the wrapper were not there; every produced entry is `approximated`. The expression is not mapped onto `displayCondition`. |
| Any other `type` | `HtmlBlock` | The raw node is preserved as JSON, flagged `html-fallback`. |

`hideOnMobile` / `hideOnDesktop` map onto `visibility`. Absent when both flags are false.

## Lossy conversions

- **Nested `multicolumn`** — a row inside a column cannot become a nested `SectionBlock`. Inner column children splice into the parent column in document order, `approximated`, note `"nested multicolumn flattened (N columns)"`. Leaves convert; geometry is the loss.
- **4+ columns** — Templatical supports five column layouts (`1`, `2`, `3`, `2-1`, `1-2`). A 4-, 5-, or 6-column `multicolumn` folds to `"3"`; overflow children append onto the last slot, `approximated`, original widths in the note.
- **Outlined buttons** — `background-color` unset plus a coloured border has no Templatical equivalent. After `createButtonBlock`, fill is written `#ffffff` so the factory `#333333` does not turn a ghost button into a dark pill. The outline is dropped; the entry is `approximated`.
- **Loops and conditionals** — Chamaileon `attrs.expression` is not Liquid and is not MJML display-condition syntax. Empty nodes `skipped`. Populated nodes convert their children and drop the branching; stuffing the expression into `displayCondition` would lie about the dialect.
- **Box paint among siblings** — a nested box with a real fill, padding, or radius that is not the sole child of an otherwise unpainted fullwidth flattens. The paint is dropped, `approximated`. A new top-level section would split the row; a nested section is illegal in a column.
- **Variable references** — `{ reference, default }` resolves to a concrete colour. The reference name does not survive as a merge tag.
- **Social icon sizes and unknown platforms** — size snaps to small / medium / large; an unknown `type` becomes `website`.
- **Heading levels** — `TitleBlock` supports levels 1 through 4. A sole `<h5>` or `<h6>` clamps to 4.
- **Block IDs** — every imported block gets a freshly generated ID. Chamaileon `eid` is discarded.

::: tip
Nested columns and painted boxes flatten because MJML forbids a section inside a column, and `addBlock` does too. Inventing a nested `SectionBlock` would fail to render; inventing extra top-level sections would split a row that was one band. Leaves convert either way. Empty loops skip rather than become one opaque `HtmlBlock`, so a populated loop of product rows stays as converted children.
:::

## Unmapped fields

- **`subjectLine`** — Templatical templates have no subject-line field. A non-empty value is named in `report.warnings`.
- **`fontFiles`** — no document-level font-file table. A non-empty object is named in `report.warnings`. Text that depended on it renders in the fallback face.
- **`title`** (the document name) — silent; it is not recipient-facing.
- **`hoverBackgroundColor`** — no hover fill on `ButtonBlock`.
- **Per-side borders on leaves** — dropped at the site they are read.
- **`fullWidthOnMobile` as a mobile-only flag** — not expressed on Templatical blocks.
- **`components[]`** — 4.x style-preset library. Out of v1; own style on the node still applies.
- **`lock` / `marker`** — editor chrome; ignored.
- **`placeholder`** — block-library chrome on `fullwidth`; skipped.

A freshly imported template commonly fails `@templatical/quality`'s accessibility rules on arrival, and the [Agent Skill](/guide/agent-skill)'s `validate` command exits non-zero on it. Images exported from Chamaileon typically carry no `alt` text, and the importer copies that gap across — inventing a description would itself be an accessibility anti-pattern. Structural validation passes; the findings are about content. Add alt text to the imported images and the findings clear.

## Further coverage

[Open a discussion](https://github.com/templatical/sdk/discussions) with a redacted snippet of your Chamaileon `getDocument()` JSON and what you're trying to achieve. We use these reports to improve `@templatical/import-chamaileon`'s coverage.

---

# Migrating from Easy Email Pro

Source: https://docs.templatical.com/guide/migration-from-easy-email-pro

This guide is for teams who've built email templates in [Easy Email Pro](https://www.easyemail.pro) — in the hosted editor, or through a product that embeds it — and want to move to Templatical's visual editor. **`@templatical/import-easy-email-pro`** converts an Easy Email Pro persist page into Templatical's `TemplateContent` format. Install it, run it, and use the sections below to finish off anything it can't map on its own.

The input is the persist JSON — `{ subject, content }` with `content.type === "page"`, or a bare page element. It is not `EditorCore.toMJML()` and not the compiled HTML.

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-easy-email-pro
```

```bash [pnpm]
pnpm add @templatical/import-easy-email-pro
```

```bash [yarn]
yarn add @templatical/import-easy-email-pro
```

```bash [bun]
bun add @templatical/import-easy-email-pro
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertEasyEmailProTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-easy-email-pro/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertEasyEmailProTemplate } from '@templatical/import-easy-email-pro';

const { content, report } = convertEasyEmailProTemplate(emailTemplate);

const editor = await init({
  container: '#editor',
  content,
});

console.log(report);
```

`convertEasyEmailProTemplate` is synchronous and returns an `ImportResult` with:

- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each produced block (`converted`, `approximated`, `html-fallback`, or `skipped`)

It accepts:

- the persist envelope `{ subject, content }` where `content.type === "page"`
- a bare page element `{ type: "page", children, attributes, data }`
- either shape serialized as a JSON string

Extra `html` / `mjml` / `thumbnail` / `id` keys on the envelope are ignored.

::: tip
Pass the persist page, not `EditorCore.toMJML()` output. `toMJML` is a compile path — MJML markup that belongs to [`@templatical/import-mjml`](/guide/migration-from-mjml), not this package. Compiled HTML belongs to [`@templatical/import-html`](/guide/migration-from-html). Guessing between the three would send markup through the wrong converter.

`$var(name)` is a design token, not a merge tag. Values resolve against the nearest table (widget `data.input` first, then page `data.variables[]`). Unresolved leftovers stay unset rather than being stored as recipient fields.

Open-source Easy Email is a different JSON (`type: "section"` / `"text"` without the `standard-` prefix). This package throws a dedicated error rather than mis-import it. Zalify ships `easyEmailToEasyEmailPro()` for that direction.
:::

## Report

Each `report.entries` item describes one produced block:

| Status | Meaning |
|---|---|
| `converted` | Mapped to a Templatical block with no loss of fidelity. |
| `approximated` | Mapped to the right block, with a clamp or flatten — `note` states what changed. |
| `html-fallback` | No block equivalent exists; the raw node is preserved as JSON inside an `HtmlBlock`. |
| `skipped` | Empty `logic` — there were no children to convert. |

```ts
console.log(report.summary);
// { total: 24, converted: 18, approximated: 5, htmlFallback: 0, skipped: 1 }

for (const entry of report.entries) {
  if (entry.status === 'approximated') {
    console.warn(`${entry.sourceTag} approximated:`, entry.note);
  }
}
```

A `note` on an `approximated` entry names the original value. `report.warnings` carries document-level drops that do not attach to a single entry — a non-empty `subject` or `fonts[]`, leftover `$var()` counts, and `mobileAttributes` when any node carried them.

## Document shape

Easy Email Pro stores a design as a tree of typed nodes under a `page`:

```
EmailTemplate { subject, content }
  content: page
    data: { globalAttributes, blockAttributes, categoryAttributes, fonts, preheader, variables[] }
    attributes: { width, background-color, content-background-color, link-color, … }
    children: standard-section | standard-wrapper | standard-hero | *_widget | AMP_* | custom
      standard-section.children: standard-column | standard-group
        standard-group.children: standard-column
          standard-column.children: leaf | nested structure
```

Leaves in the documented element list: `standard-paragraph`, `standard-h1`–`h4`, `standard-button`, `standard-image`, `standard-divider`, `standard-spacer`, `standard-navbar` + `standard-navbar-link`, `standard-social` + `standard-social-element`, `standard-table2`, `line-break`, `html-block-node`, `marketing-countdown`, `placeholder`. `uid` / `id` / `thumbnail` are discarded — Templatical mints its own IDs. `placeholder` is editor chrome and is skipped.

- **Easy Email Pro** stores this persist tree, plus optional `variables[]` (design tokens) and page `data`.
- **Templatical** stores templates as a JSON tree of typed blocks (`SectionBlock`, `ParagraphBlock`, etc.) and renders that tree to MJML at export time.

`@templatical/import-easy-email-pro` walks `page.children`, flattens `standard-group` and widgets, and constructs the equivalent Templatical blocks. The mapping table below is what it implements.

## Visual rebuild

For a handful of templates, rebuilding by hand next to an Easy Email Pro preview is faster than installing a package:

1. Open the Easy Email Pro design — the editor itself, or a preview of its compiled HTML — in one window.
2. Open the Templatical editor (or [the playground](https://play.templatical.com)) side-by-side.
3. Use that preview as your visual target. Do not feed `EditorCore.toMJML()` into this converter.
4. Drag in the equivalent Templatical blocks (see the [mapping table](#easy-email-pro-node-mapping) below).
5. Copy text content directly. Re-host images via your media library.
6. Reproduce styling using Templatical's [design tokens](/guide/theming).

For a larger batch, run `@templatical/import-easy-email-pro` first and use this path only to finish off what it left `approximated`, `skipped`, or as an `html-fallback` block.

## Renderer verification

Once you have a template in Templatical — imported or rebuilt by hand:

```ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(content);
```

A visual comparison against Easy Email Pro's own preview catches what the report cannot. Several of the losses below — `mobileAttributes`, hero overlay, page `fonts[]` — are dropped at the site they are read, with a note when the converter has somewhere to attach one.

There is no round-trip oracle. Templatical does not render Easy Email Pro JSON, and this package does not consume `EditorCore.toMJML()`.

## Node mapping {#easy-email-pro-node-mapping}

| Easy Email Pro | Templatical block | Notes |
|---|---|---|
| `page` | Template `settings` | `width` → `settings.width`; `background-color` → `settings.backgroundColor` after `$var`; `globalAttributes["font-family"]` / `color` → `fontFamily` / `textColor`; `link-color` → `linkColor` when set; `preheader` → `settings.preheaderText` when non-empty. `content-background-color` is the default section fill, not the page background. |
| `standard-section` | `SectionBlock` | Own `background-color` → `section.styles.backgroundColor`; else the page `content-background-color`. `padding-*` → padding. Direct `standard-column` children, or the columns inside a single `standard-group`. |
| `standard-column` | Section `columns` / `children[i]` | Percent widths (`"50%"`, `"33.33%"`) match Templatical's five layouts. No report entry of its own. |
| `standard-group` | Flattened columns; `stackOnMobile: false` | A group of columns on a section is that section's column structure. Nested group-in-column flattens, `approximated`. |
| `standard-wrapper` | Inner section(s) `wrapper` | Outer `background-color` / padding → `section.wrapper`. One inner section → converted. Several inners → wrapper copied onto each, `approximated`. |
| `standard-hero` | 1-col `SectionBlock` | Children walk as h1 / paragraph / button. `background-color` onto the section; `background-url` prepends an `ImageBlock` (stacked, not overlay), `approximated`. |
| `placeholder` | Skip | Editor chrome. No entry. |
| `standard-paragraph` | `ParagraphBlock` | Content from Slate children. Stays a paragraph even at a large `font-size`. |
| `standard-h1`–`h4` | `TitleBlock` | `level` 1–4 from the type. |
| `standard-button` | `ButtonBlock` | Label from children, not `data.content`. `href` → `url`. Unset fill plus `border-enabled` is outlined — fill becomes `#ffffff` (not the factory `#333333`), `approximated`. |
| `standard-image` | `ImageBlock` | `src`, `alt`; `href` → `linkUrl`. |
| `standard-divider` | `DividerBlock` | `border-color` / `border-width` / `border-style`. |
| `standard-spacer` | `SpacerBlock` | Height from `height` px. |
| `standard-navbar` + `standard-navbar-link` | `MenuBlock` | Link text from children; `href` → `url`; `target === "_blank"` → `openInNewTab`. |
| `standard-social` + `standard-social-element` | `SocialIconsBlock` | Platform inferred from `href` hostname, then `src` path. Custom PNG `src` is dropped, `approximated`. |
| `standard-table2` / `tr` / `td` | `TableBlock` | Converted. |
| `line-break` | `<br>` | Inside the parent rich-text. No entry. |
| `html-block-node` | HTML fragment | Inside the parent rich-text. No entry. |
| `marketing-countdown` | Overlay text + `ImageBlock` of `src` | The GIF is the timer. Does not emit `type: "countdown"` (Cloud-only). `approximated`. |
| kit `common-video` | `VideoBlock` | Converted when a URL is present; otherwise `html-fallback`. |
| kit shopwindow / qr-code / countdown-v2 | `HtmlBlock` | `JSON.stringify(node)`, `html-fallback`. |
| `section_widget` / `wrapper_widget` | Children | `$var` from `data.input`; every produced entry `approximated`. |
| AMP_* | `HtmlBlock` | `JSON.stringify(node)`, `html-fallback`. |
| `logic` | Children, or skip | Empty → `skipped`. Non-empty → children convert as if the wrapper were not there; every produced entry `approximated`. The expression is not mapped onto `displayCondition`. |
| Any other `type` | `HtmlBlock` | The raw node is preserved as JSON, flagged `html-fallback`. |

`visible: "desktop"` / `"mobile"` maps onto `visibility`. Absent when the key is missing.

## Lossy conversions

- **`$var` unresolved leftovers** — `$var(name)` resolves against widget `data.input`, then page `variables[]`. A name with no value is unset (`""` / leftover `$var(…)` do not become merge tags). One document-level warning names how many values resolved and how many were left unresolved.
- **`mobileAttributes`** — a second attribute bag for the mobile viewport. Templatical has no per-viewport padding. Dropped. One document-level warning when any node carried them.
- **Hero overlay** — `standard-hero` has no block equivalent. Children walk into a 1-col section; `background-url` becomes a leading `ImageBlock`, stacked, not overlaid. Overlay-on-image is the loss; editable CTAs survive. `approximated`.
- **Outlined buttons** — `border-enabled: true` plus an unset fill has no Templatical equivalent. After `createButtonBlock`, fill is written `#ffffff` so the factory `#333333` does not turn a ghost button into a dark pill. The outline is dropped; the entry is `approximated`.
- **4+ columns** — Templatical supports five column layouts (`1`, `2`, `3`, `2-1`, `1-2`). A 4-, 5-, or 6-column section folds to `"3"`; overflow children append onto the last slot, `approximated`, original widths in the note.
- **Nested group flatten** — a `standard-group` inside a column cannot become a nested `SectionBlock`. Inner column children splice into the parent column in document order, `approximated`, note `"nested group flattened (N columns)"`. Leaves convert; geometry is the loss.
- **Custom social PNGs** — `standard-social-element` carries a custom PNG `src`. `SocialIconsBlock` has no custom src — it picks an icon from `platform` + `iconStyle`. The PNG is the loss; the entry is `approximated` when a custom `src` was present.
- **AMP** — `AMP_*` nodes have no Templatical equivalent. Preserved as JSON inside an `HtmlBlock`, `html-fallback`.
- **`logic`** — Pro `logic.condition` / `logic.iteration` compile to Liquid (or a custom engine) at `toMJML` time. They are not `displayCondition.{ before, after }`. Empty nodes `skipped`. Populated nodes convert their children and drop the branching.
- **Countdown as GIF** — `marketing-countdown` is overlay text plus an `ImageBlock` of `attributes.src`. Templatical's `countdown` block is Cloud-only and blank on OSS, so this package does not emit it. The GIF is a static image; the timer does not tick.

::: tip
Nested groups flatten because MJML forbids a section inside a column, and `addBlock` does too. Inventing a nested `SectionBlock` would fail to render. Hero children walk rather than html-fallback the whole subtree so editable CTAs survive; overlay-on-image is what Templatical cannot express. Empty logic skips rather than become one opaque `HtmlBlock`, so a populated branch of product rows stays as converted children. Countdown stays an image so an OSS renderer does not blank the block.
:::

## Unmapped fields

- **`subject`** — Templatical templates have no subject-line field. A non-empty value is named in `report.warnings`.
- **`fonts[]`** — no document-level font-file table. A non-empty array is named in `report.warnings`. Text that depended on it renders in the fallback face.
- **`headStyles`** — no document-level head-style table. A non-empty value is named in `report.warnings`.
- **`thumbnail` / `id`** — silent; they are not recipient-facing.
- **`classAttributes`** — skipped in v1. One warning when the object is non-empty.
- **`breakpoint`** — silent. Templatical mobile is 375 / MJML 480; this package does not invent a setting.

A freshly imported template commonly fails `@templatical/quality`'s accessibility rules on arrival, and the [Agent Skill](/guide/agent-skill)'s `validate` command exits non-zero on it. Images exported from Easy Email Pro typically carry no `alt` text, and the importer copies that gap across — inventing a description would itself be an accessibility anti-pattern. Structural validation passes; the findings are about content. Add alt text to the imported images and the findings clear.

## Further coverage

[Open a discussion](https://github.com/templatical/sdk/discussions) with a redacted snippet of your Easy Email Pro persist JSON and what you're trying to achieve. We use these reports to improve `@templatical/import-easy-email-pro`'s coverage.

---

# Migration from HTML

Source: https://docs.templatical.com/guide/migration-from-html

The `@templatical/import-html` package converts HTML email templates into Templatical's `TemplateContent` format. It's designed for the table-based HTML that real marketing emails actually ship — output of MJML, Mailchimp/SendGrid/Campaign Monitor exports, hand-coded campaigns.

::: warning
This package is in active development. Modern (flex/grid) HTML is preserved via HTML-fallback blocks rather than re-decomposed; review your converted templates before using them in production.
:::

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-html
```

```bash [pnpm]
pnpm add @templatical/import-html
```

```bash [yarn]
yarn add @templatical/import-html
```

```bash [bun]
bun add @templatical/import-html
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertHtmlTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-html/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertHtmlTemplate } from '@templatical/import-html';

// Load the raw HTML source of an email
const res = await fetch('/path/to/email.html');
const html = await res.text();

// Convert to Templatical format
const { content, report } = convertHtmlTemplate(html);

// Use in the editor
const editor = await init({
  container: '#editor',
  content,
});

// Check the conversion report for any issues
console.log(report);
```

The function returns an `ImportResult` with:
- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each element (`converted`, `approximated`, `html-fallback`, or `skipped`)

## Element Mapping

HTML elements map to Templatical equivalents:

| HTML Element | Templatical Block | Status |
|---|---|---|
| `<h1>` – `<h4>` | `title` | Converted (level preserved) |
| `<h5>` – `<h6>` | `title` | Converted (clamped to level 4) |
| A heading wrapped in a `<div>`, `<center>` or `<main>` | `title` | Converted (wrapper unwrapped) |
| `<p>` / text-only `<div>` / `<span>` | `paragraph` | Converted |
| Text-only `<td>` in a layout table | `paragraph` | Converted |
| Bare text at body level or directly inside a wrapper | `paragraph` | Converted |
| `<img>` | `image` | Converted |
| `<a>` styled as button (background color, padding, border-radius, or `display: inline-block`) | `button` | Converted |
| `<a>` (text link) | folded into the surrounding `paragraph` | Converted |
| `<a>` wrapping an `<img>` | `image` with `linkUrl` | Converted |
| `<hr>` | `divider` | Converted |
| Empty `<td>` with explicit height | `spacer` | Converted |
| `<td>` whose entire content is one styled text `<a>` | `button` | Converted (cell-as-button pattern) |
| `<table>` (layout, multi-row/column) | `section` (one per `<tr>`) | Converted |
| `<table>` (data table — text-only cells) | `html` | HTML fallback |
| Unknown / custom elements | `html` | HTML fallback |

Anything that can't be mapped is preserved verbatim inside an HTML block, so no visible content is lost.

A cell mixing copy with a link becomes one `paragraph` holding both, with the `<a>` and its `href` inline. A cell whose entire content is one styled text `<a>` reads as a button.

A `<div>`, `<center>` or `<main>` that wraps a table produces no block of its own: the importer descends into it, at any nesting depth, and maps the tables it finds. A wrapper holding only text keeps its `paragraph` mapping, and a wrapper whose whole content is one heading is unwrapped so the heading is what gets mapped.

## Inline Formatting

`<br>`, `<em>`, `<strong>`, `<i>`, `<b>`, `<u>`, `<small>`, `<sub>` and `<sup>` stay inside the text they belong to. A run of them, together with the bare text around it, becomes one `paragraph` whose colour, size and alignment come from the containing cell — so `Hello<br>World` in a `<td>` imports as a single paragraph holding both words and the line break.

A text `<a>` folds into that run, keeping its `href`, so a sentence containing a link arrives as one paragraph rather than as a link torn out of its copy. An `<a>` wrapping an `<img>` becomes an `image` with `linkUrl` from a non-empty href. An `<a>` wrapping both an image and text becomes that `image` plus a sibling `paragraph` that keeps the remaining `<a>`.

::: tip
Bare text counts as content here. A cell walk that visited only element children dropped the words between two inline tags, and dropped a loose sentence sitting beside a table at body or wrapper level. Both are now kept.
:::

## Column Layout Conversion

Each `<tr>` in a layout table becomes a `SectionBlock`. The row's direct `<td>` / `<th>` children are the layout:

| Cells per row | Templatical Layout |
|---|---|
| 1 | `'1'` |
| 2 | `'2'` or `'2-1'` / `'1-2'`, by declared width |
| 3 | `'3'` |
| 4+ | merged into `'1'`, with a warning and an `approximated` report entry |

### Column ratios

A two-cell row picks between `'2'`, `'2-1'` and `'1-2'` from the widths its cells declare — a `width` attribute, a `style="width:…"`, or the share in an `mj-column-per-*` class name. The closest of those layouts wins, so `350` / `190` reads as `'2-1'` and `33.33%` / `66.66%` as `'1-2'`.

A ratio no layout expresses is imported as the equal split for that cell count and reported as `approximated`, with a note naming the widths that were measured:

```txt
Column widths 24.1% / 51.9% / 24.1% have no Templatical equivalent.
The section was imported as 3 equal columns.
```

### Wrapper rows

Table-based emails wrap their real layout in one-cell tables. A row holding a single cell whose content is nothing but tables is descended instead of becoming a section, so the column count is read off the row that declares it. The descent applies only when that cell holds no content beside its tables and the row carries no background colour and no padding — a row failing either becomes a section of its own, because the section is what carries a row's background and padding.

### Gutter rows

A row that pads its content with empty cells — `&nbsp;` either side of a centred container — is read as the single column it lays out, not as one column per cell. The signal is content: a cell holding nothing takes part in no layout.

### Sibling column containers

A single `<td>` holding one `display: inline-block` container per column is read as a column set, with the column count taken from the number of containers and the ratio from their declared widths. This is how hybrid templates and compiled MJML state their columns — MJML puts a whole section's columns into one cell as sibling `<div class="mj-column-per-*">` elements — so such a row has no cell count to read.

Every container must be laid out side by side, none may be empty, and no text of the cell's own may sit beside them. A cell failing any of those is a single column.

::: tip
`display: inline-block` is what makes a set of containers a set of columns, since a block-level `<div>` stacks instead. Requiring it keeps two stacked divs from being read as a layout the source never stated.
:::

### Nesting

Templatical sections cannot nest. Tables nested inside a `<td>` are flattened — their inner blocks are merged into the parent cell. A nested row with more than one cell loses its columns that way, and `report.entries` records it as `approximated` with a note.

## CSS Handling

`<style>` blocks are resolved onto matching elements before conversion:

- **Inline styles always win** over rules from `<style>` blocks.
- **`@media` queries are skipped** — they would always apply if flattened. Email is rendered at one viewport in the editor.
- **`@font-face`, `@keyframes`, `@supports`** are skipped.
- **Pseudo-classes (`:hover`, `::before`)** are skipped.
- **External stylesheets (`<link rel="stylesheet">`)** are not fetched.
- **`!important` markers** are dropped (no specificity conflicts to resolve).

For best fidelity, inline your styles before importing. Most production marketing emails are already inlined by their sending pipeline.

## Template Settings

Global template settings are extracted from the document:

- **Width** — outermost `<table>` `width` attribute or `style="width:…"`. Defaults to `600`.
- **Background color** — `<body>` `background-color` style. Defaults to `#ffffff`.
- **Font family** — `<body>` `font-family` style. Defaults to `Arial`.
- **Preheader text** — first `<div style="display:none">` near the top of the body, by convention.

## Known Limitations

- **Modern HTML (flex / grid / `<div>` layouts)** — produces low-fidelity output, mostly HTML-fallback blocks. The importer is tuned for table-based email HTML.
- **Custom fonts** — `@font-face` rules are not imported. Add fonts manually via the editor's [`fonts` config option](/guide/fonts).
- **Display conditions / merge tags** — proprietary merge tag syntax (<code v-pre>{{var}}</code>, `*|VAR|*`, `<%= var %>`) is preserved as raw text. Recreate using Templatical [merge tags](/guide/merge-tags) or [display conditions](/guide/display-conditions).
- **External resources** — `<link>`, external stylesheets, web fonts, and remote images are not fetched. Image `src` URLs are preserved as-is.
- **Outlook MSO conditional comments** — preserved as HTML inside their containing block (they're inert in non-Outlook clients anyway).
- **`<form>` / `<input>` / `<button>` form controls** — preserved as HTML-fallback. Most email clients block form submission; rebuild the call-to-action as a button linking to a hosted form.
- **Rows of more than three cells** — `ColumnLayout` holds at most three columns, so a wider row is merged into one and reported. Ratios outside `'2'` / `'2-1'` / `'1-2'` / `'3'`, such as a `1-2-1` sidebar pair, have no equivalent either and import as the equal split.
- **AMP for Email** — not currently supported in Templatical.

## Verifying Converted Templates

After conversion, review the output in the editor to check for:

1. **Element classification** — review `report.entries` for entries with `status: 'approximated'` or `status: 'html-fallback'`.
2. **Image URLs** — relative paths and CID references won't resolve in the preview; replace with absolute URLs.
3. **Column proportions** — automatic mapping picks the closest standard layout; fine-tune in the section settings panel.
4. **Spacing and padding** — `padding` shorthand is parsed faithfully, but margin/spacing on bare cells may need touch-up.
5. **HTML-fallback blocks** — anything that landed in an HTML block can be edited inline or replaced with first-class blocks.

## Reading the Report

```ts
const { content, report } = convertHtmlTemplate(html);

console.log(report.summary);
// { total: 12, converted: 10, approximated: 1, htmlFallback: 1, skipped: 0 }

for (const entry of report.entries) {
  if (entry.status === 'html-fallback') {
    console.warn(
      `Element <${entry.sourceTag}> preserved as HTML:`,
      entry.note,
    );
  }
}

for (const warning of report.warnings) {
  console.warn(warning);
}
```

### Sections in the report

`report.entries` accounts for the sections alongside the leaf blocks, so the entries reconcile against `content.blocks`:

- One entry per section, with `sourceTag: 'tr'` and `templaticalBlockType: 'section'`. Its status is `converted` when every cell kept its own column, and `approximated` with a note when cells were merged or when the ratio had no equivalent.
- One entry with `sourceTag: 'body'` and a note when loose top-level content is grouped into a synthetic single-column section.
- One entry with `templaticalBlockType: null` and a note for a nested row whose columns were dropped.

A wrapper the importer descends through contributes no entry of its own: nothing is created and nothing lost. So a heading lifted out of a `<div>` is reported under its own tag, and a cell's column containers appear nowhere.

::: warning Entry counts changed
Sections and lost layouts are reported where they previously were not, so `report.summary.total` is higher for the same document, and `approximated` now covers cases that raised only a `warnings` string or went unreported. Code that asserts on exact totals needs updating; code that filters by `status` or `templaticalBlockType` does not.
:::

---

# Migrating from MJML

Source: https://docs.templatical.com/guide/migration-from-mjml

This guide is for teams who've been authoring email templates in raw [MJML](https://mjml.io) (with editors like VS Code, an internal CLI, or a hand-rolled build pipeline) and want to move to Templatical's visual editor. **`@templatical/import-mjml`** converts an MJML document into Templatical's `TemplateContent` format directly — install it, run it, and use the sections below to finish off anything it can't map on its own.

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-mjml
```

```bash [pnpm]
pnpm add @templatical/import-mjml
```

```bash [yarn]
yarn add @templatical/import-mjml
```

```bash [bun]
bun add @templatical/import-mjml
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertMjmlTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-mjml/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertMjmlTemplate } from '@templatical/import-mjml';

// Load the raw MJML source of an email
const res = await fetch('/path/to/email.mjml');
const mjml = await res.text();

// Convert to Templatical format
const { content, report } = convertMjmlTemplate(mjml);

// Use in the editor
const editor = await init({
  container: '#editor',
  content,
});

// Check the conversion report for any issues
console.log(report);
```

`convertMjmlTemplate` is synchronous and returns an `ImportResult` with:
- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each source element (`converted`, `approximated`, `html-fallback`, or `skipped`)

## Reading the report

Each `report.entries` item describes one source element:

| Status | Meaning |
|---|---|
| `converted` | Every attribute with a Templatical home was carried across. |
| `approximated` | Mapped to the right block, but a value was coerced to fit a closed range — `note` states the original value. |
| `html-fallback` | No block equivalent exists; the original markup is preserved in an `HtmlBlock`. |
| `skipped` | Nothing was produced (`templaticalBlockType: null`). |

```ts
console.log(report.summary);
// { total: 24, converted: 21, approximated: 2, htmlFallback: 1, skipped: 0 }

for (const entry of report.entries) {
  if (entry.status === 'approximated') {
    console.warn(`<${entry.sourceTag}> approximated:`, entry.note);
  }
}
// <mj-section> approximated: Column widths 40%, 60% have no exact Templatical layout; resolved to "1-2".
```

A `note` on an `approximated` entry always names the value it replaced, so diffing `report.entries` between two runs shows exactly what a migration changed.

## Conversion model

This is a slightly counter-intuitive migration. Templatical's renderer produces *MJML output* — so on the surface, MJML and Templatical look identical. But:

- **MJML** is a markup language. You write XML-like tags (`<mj-section>`, `<mj-column>`, `<mj-text>`) and the MJML compiler turns them into table-based HTML.
- **Templatical** stores templates as a JSON tree of typed blocks (`SectionBlock`, `ParagraphBlock`, etc.) and renders that tree to MJML at export time.

To bring an MJML template into Templatical, you parse the MJML and construct an equivalent JSON tree — including resolving MJML's own attribute-inheritance rules (`mj-all`, per-tag defaults, `mj-class`) before mapping each element. `@templatical/import-mjml` does exactly that; the mapping table below is what it implements.

## Path 1 — Rebuild visually using your MJML as a reference

For a handful of templates, rebuilding by hand next to your MJML source is often faster than installing a package:

1. Open your MJML source in your editor of choice.
2. Open the Templatical editor (or [the playground](https://play.templatical.com)) side-by-side.
3. Compile your MJML to HTML once and preview it — that's your visual target.
4. Drag in the equivalent Templatical blocks (see the [mapping table](#mjml-tag-mapping) below).
5. Copy text content directly. Re-host images via your media library.
6. Reproduce styling using Templatical's [design tokens](/guide/theming) instead of inline `mj-attributes`.

Most MJML templates port in 10–20 minutes once you've done one or two. For a larger batch, run `@templatical/import-mjml` first and use this path only to finish off anything it left as an HTML-fallback block.

## Path 2 — Use Templatical's renderer to verify your work

Once you have a template in Templatical — imported or rebuilt by hand:

```ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(content);
// Compare this MJML against your original MJML source.
```

Run a diff between the original and Templatical-generated MJML to spot structural differences. This is a useful sanity check before bulk-migrating.

## MJML tag mapping {#mjml-tag-mapping}

| MJML tag | Templatical block | Notes |
|---|---|---|
| `mj-section` (containing `mj-column`s) | `SectionBlock` with `columns` | Multi-column layouts work the same way; column widths come from MJML's `width` attribute or are equally distributed. |
| `mj-column` | Section column | A column holds a list of nested blocks. |
| `mj-group` | `SectionBlock.stackOnMobile: false` | Not a block at all — marks the section's columns to stay side by side on mobile instead of stacking. |
| `mj-text` | `TitleBlock` / `TableBlock` / `MenuBlock` / `ParagraphBlock` | Resolved structurally: a single heading root becomes `TitleBlock`, a single `<table>` becomes `TableBlock`, span-separated top-level anchors with no paragraph wrapper and no non-whitespace text-node siblings become `MenuBlock`, a sentence of copy plus a trailing `<a>` becomes `ParagraphBlock`, anything else becomes `ParagraphBlock`. |
| `mj-image` | `ImageBlock` | `src`, `alt`, `href`, `width`, padding. |
| `mj-button` | `ButtonBlock` | `href`, `background-color`, `color`, font, padding. |
| `mj-divider` | `DividerBlock` | `border-color`, `border-width`, padding. |
| `mj-spacer` | `SpacerBlock` | `height`. |
| `mj-social` (with `mj-social-element`) | `SocialIconsBlock` | Each `mj-social-element` → a `SocialIcon` entry. Platform comes from `name`, the `src` filename (pack suffixes such as `-round-outlined` stripped), or `alt`. |
| `mj-navbar` (with `mj-navbar-link`) | `MenuBlock` | Each link → `MenuItemData`. |
| `mj-table` | `TableBlock` | Maps `<tr>`/`<td>`/`<th>` rows and cells to Templatical's table data; a leading `<th>` row sets `hasHeaderRow`. |
| `mj-raw` | `HtmlBlock` | Inner markup preserved verbatim. |
| `mj-wrapper` | `SectionBlock.wrapper` | The section's outer band, **not a section of its own**. One section inside folds into its `wrapper`; several share the same band, flagged `approximated`. |
| `mj-hero`, `mj-carousel`, `mj-accordion` | `HtmlBlock` | Converted to an HTML block with the original markup preserved. |
| `mj-head` content | Template `settings` | `mj-preview` → `preheaderText`; `mj-attributes`/`mj-font`/`mj-style` set the document's font, text color, and link color/underline. `mj-title` has no settings equivalent and is dropped with a warning. |

## Where the mapping is lossy

MJML produced by Templatical's own renderer round-trips through the importer with no approximations to layout, style, or display conditions. The one gap is block type: a `VideoBlock` and an `HtmlBlock` both render as plain MJML with no marker of their origin, so each re-imports as a different kind of block (see below). Hand-written MJML converts cleanly for every tag in the mapping table above; anything the table doesn't cover lands as an `HtmlBlock` holding the original markup. Within what the table does cover, a few conversions are approximations rather than exact matches:

- **Column geometry** — Templatical supports five column layouts (`1`, `2`, `3`, `2-1`, `1-2`). MJML allows any number of columns at any width, so a ratio outside those five resolves to the nearest layout, and a fourth or later column's content folds into the last column.
- **Social icon sizes** — `SocialIconsBlock` supports three sizes (24px, 32px, 48px). An `mj-social-element`'s `icon-size` outside those three resolves to the nearest one.
- **Heading levels** — an `<h5>` or `<h6>` inside `mj-text` clamps to heading level 4, the highest a `TitleBlock` supports.
- **Video blocks** — a `VideoBlock` renders the same way a linked `ImageBlock` does, so nothing in the MJML marks it as video. Importing that markup back produces an `ImageBlock` holding the same thumbnail and link; content survives, the block type does not.
- **HTML blocks** — for the same reason, an `HtmlBlock`'s content renders as plain `mj-text` markup with nothing marking it as HTML. Importing it back produces a `ParagraphBlock` holding the same markup.
- **Block IDs** — every imported block gets a freshly generated ID. IDs never appear in rendered MJML, so nothing that keys off one — a Cloud comment thread, for example — survives a round trip.

## Things that don't map automatically

- **`mj-include`** — the importer reads a single MJML string with no filesystem access, so an unresolved `<mj-include>` is skipped with a warning naming its `path` attribute. Inline the included content before importing.
- **Custom MJML components** — an unrecognized `mj-*` tag lands as an `HtmlBlock` holding its rendered markup automatically. Reimplement it as a [Templatical custom block](/guide/custom-blocks) if you want it editable as a native block instead.

## Further help

[Open a discussion](https://github.com/templatical/sdk/discussions) with a redacted snippet of your MJML and what you're trying to achieve. We use these reports to improve `@templatical/import-mjml`'s coverage.

---

# Migrating from Stripo

Source: https://docs.templatical.com/guide/migration-from-stripo

This guide is for teams who've built email templates in [Stripo](https://stripo.email) — in the hosted editor, or through a product that embeds the Stripo Plugin — and want to move to Templatical's visual editor. **`@templatical/import-stripo`** converts Stripo HTML into Templatical's `TemplateContent` format. Install it, run it, and use the sections below to finish off anything it can't map on its own.

Stripo stores two different HTML surfaces. The converter auto-detects which one you passed. There is no mode flag.

| Surface | Where it comes from | Discriminator |
|---|---|---|
| Plugin / editor storage | `getTemplateData()` → `{ html, css }` | `esd-stripe`, `esd-structure`, `esd-block-*` on elements |
| Compiled export | File → HTML, or `compileEmail({ callback })` | `es-wrapper`, `es-content-body`, `es-header-body` — no `esd-*` on elements |

A leftover `.esd-block-html` rule inside a stylesheet is not a discriminator. CSS-only leftovers convert as generic HTML.

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-stripo
```

```bash [pnpm]
pnpm add @templatical/import-stripo
```

```bash [yarn]
yarn add @templatical/import-stripo
```

```bash [bun]
bun add @templatical/import-stripo
```

:::

### Without a build step (CDN)

```html
<script type="module">
  import { convertStripoTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-stripo/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertStripoTemplate } from '@templatical/import-stripo';

// Plugin host — what you stored from getTemplateData():
const { html, css } = await window.StripoApi.getTemplateData();
const { content, report } = convertStripoTemplate(html, { css });

// Marketer File → HTML export:
const exported = await fetch('/path/to/export.html').then((r) => r.text());
const compiled = convertStripoTemplate(exported);

const editor = await init({
  container: '#editor',
  content: compiled.content,
});
```

`convertStripoTemplate` is synchronous and returns an `ImportResult` with:

- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each source element (`converted`, `approximated`, `html-fallback`, or `skipped`)

Pass `options.css` when converting plugin storage. It is ignored on compiled exports (those already inline their styles).

Unrecognized HTML (no Stripo class attributes) is converted through `@templatical/import-html` and the report carries a warning naming that fallback.

## Reading the report

Each `report.entries` item describes one source element:

| Status | Meaning |
|---|---|
| `converted` | Mapped to a Templatical block with no loss of fidelity. |
| `approximated` | Mapped to a Templatical block, with a clamp or flatten — `note` states what changed. |
| `html-fallback` | No block equivalent; the original markup is preserved in an `HtmlBlock`. |
| `skipped` | Reserved for parity with the other `@templatical/import-*` packages; this converter does not currently produce it. |

```ts
console.log(report.summary);
// { total: 18, converted: 16, approximated: 1, htmlFallback: 1, skipped: 0 }

for (const entry of report.entries) {
  if (entry.status === 'approximated') {
    console.warn(`<${entry.sourceTag}> approximated:`, entry.note);
  }
}
```

## Path 1 — Rebuild visually using your Stripo export as a reference

For a handful of templates, rebuilding by hand next to a compiled preview is often faster than installing a package:

1. Export File → HTML from Stripo and open it in a browser — that's your visual target.
2. Open the Templatical editor (or [the playground](https://play.templatical.com)) side-by-side.
3. Drag in the equivalent Templatical blocks (see the mapping tables below).
4. Copy text content directly. Re-host images via your media library.

Most Stripo templates port in 10–20 minutes once you've done one or two. For a larger batch, run `@templatical/import-stripo` first and use this path only to finish off what it left `approximated` or as an `html-fallback` block.

## Plugin HTML mapping {#plugin-mapping}

Plugin storage is a table tree labelled with `esd-*` classes. Each `esd-structure` becomes a `SectionBlock`; its `esd-container-frame` children are the columns.

| Stripo marker | Templatical block | Notes |
|---|---|---|
| `esd-structure` with 1–3 `esd-container-frame`s | `SectionBlock` (`columns` `"1"` / `"2"` / `"3"`) | Frames are the columns. |
| `esd-structure` with 4+ frames | `SectionBlock` `columns: "1"` | Flattened; `approximated`. |
| `esd-block-text` | `title` / `paragraph` | Generic HTML mapping of the inner markup. |
| `esd-block-image` | `image` | Generic HTML mapping of the inner `<img>`. |
| `esd-block-button` | `button` | `href`, text, `target="_blank"` → `openInNewTab`. Inline `background` / `color` / `border-radius` when present. |
| `esd-block-menu` (2+ item cells) | `menu` | One `MenuItemData` per item cell. |
| `esd-block-menu` (1 item cell) | `paragraph` (or the inner mapping) | A stacked step, not a nav — Password-reset rows stay copy. |
| `esd-block-social` | `social` | Platform from `title` / `src` / `alt`; unknown names become `website`. |
| `esd-block-spacer` | `spacer` | Height from `style` or the `height` attribute. |
| `esd-block-html` | `html` | Inner markup preserved. |

## Compiled HTML mapping {#compiled-mapping}

Compiled export drops `esd-*` from elements and keeps `es-*` leftovers. Stripes (`es-header` / `es-content` / `es-footer`) become sections. Columns are sibling floated tables (`es-left` / `es-right`), not cell counts.

| Stripo marker | Templatical block | Notes |
|---|---|---|
| `es-header` / `es-content` / `es-footer` | `SectionBlock` | One section per top-level stripe. Fill comes from the inner `es-*-body` `background-color` (style wins over `bgcolor`). |
| 1–3 `es-left` / `es-right` siblings | `columns` `"1"` / `"2"` / `"3"` | Floated tables, not `<td>` count. |
| 4+ floated siblings | `columns: "3"` | Extra columns merge into the third slot; `approximated`. |
| `a.es-button` | `button` | Inline `background` / `color` / `border-radius`, including from the wrapping `es-button-border`. |
| `table.es-menu` (2+ item cells) | `menu` | |
| `table.es-menu` (1 item cell) | inner mapping, not `menu` | Same stacked-step rule as the plugin path. |
| `table.es-social` | `social` | Same platform mapping as the plugin path. |
| `es-spacer` | `spacer` | |

Everything else in a stripe goes through `@templatical/import-html` (headings, paragraphs, images, dividers).

## Where the mapping is lossy

- **Column geometry** — Templatical supports five column layouts (`1`, `2`, `3`, `2-1`, `1-2`). Plugin HTML with 4+ frames flattens to one column. Compiled HTML with 4+ floated tables keeps three slots and folds the rest into the last.
- **Social icon `alt`** — platforms are inferred; the original `alt` string is not stored on `SocialIcon`.
- **Block IDs** — every imported block gets a freshly generated ID.
- **AMP / timers / modules** — no Templatical equivalent; inner markup lands as `html` or is skipped with a warning.
- **Inline CSS vs. plugin CSS** — compiled exports already inline. Plugin CSS must be passed as `options.css` or styles that lived only in that stylesheet are missing from the conversion.

## Things that don't map automatically

- **A round-trip back into Stripo** — the output is Templatical JSON, not Stripo editor HTML.
- **Generic HTML that was never a Stripo document** — use [`@templatical/import-html`](/guide/migration-from-html) directly. Passing it here still converts, with a warning.

## Further help

[Open a discussion](https://github.com/templatical/sdk/discussions) with a redacted snippet of your Stripo HTML and what you're trying to achieve. We use these reports to improve `@templatical/import-stripo`'s coverage.

---

# Migrating from Topol

Source: https://docs.templatical.com/guide/migration-from-topol

This guide is for teams who've built email templates in [Topol.io](https://topol.io)'s drag-and-drop editor — directly, or through a product that embeds it — and want to move to Templatical's visual editor. **`@templatical/import-topol`** converts a Topol design into Templatical's `TemplateContent` format directly — install it, run it, and use the sections below to finish off anything it can't map on its own.

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-topol
```

```bash [pnpm]
pnpm add @templatical/import-topol
```

```bash [yarn]
yarn add @templatical/import-topol
```

```bash [bun]
bun add @templatical/import-topol
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertTopolTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-topol/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertTopolTemplate } from '@templatical/import-topol';

// Topol hands you the design directly from its editor's onSave callback.
// Fetched from its REST API instead, it sits under "definition" (the
// template endpoint) or "json" (the predefined-templates endpoint) — not
// the response object itself.
const res = await fetch('https://app.topol.io/api/templates/123').then((r) => r.json());

// Convert to Templatical format
const { content, report } = convertTopolTemplate(res.data.definition);

// Use in the editor
const editor = await init({
  container: '#editor',
  content,
});

// Check the conversion report for any issues
console.log(report);
```

`convertTopolTemplate` is synchronous and returns an `ImportResult` with:
- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each source node (`converted`, `approximated`, `html-fallback`, or `skipped`)

It also accepts the design serialized as a JSON string, for callers that store or transmit it that way.

::: tip
Pass the design object itself, not Topol's whole API response. Topol's editor hands it to you directly, as the first of four arguments to `onSave` — no envelope to unwrap there. Its REST APIs do wrap it, and which field holds the design depends on the endpoint: the template-retrieval endpoint returns `{ data: { id, name, screenshot_url, definition, html } }`, so use `.data.definition`; the predefined-templates endpoint returns the design under `json`, alongside `html`. Passing either envelope by mistake, as an object or as a JSON string, is recognised — the converter sees a `json` or `definition` key on a root with the wrong shape and names both fields in the error. A root with a different shape altogether — not an object, unparseable JSON, or an object with neither key — still throws, naming what it expected instead. The design is unwrapped explicitly rather than detected automatically, because guessing which field holds it risks importing an envelope's `html` string — output that belongs to `@templatical/import-html`, not this package.
:::

## Reading the report

Each `report.entries` item describes one source node:

| Status | Meaning |
|---|---|
| `converted` | Every attribute with a Templatical home was carried across. |
| `approximated` | Mapped to the right block, but a value was coerced to fit a closed range — `note` states what was coerced. |
| `html-fallback` | No block equivalent exists; the raw node is preserved as JSON inside an `HtmlBlock`. |
| `skipped` | Reserved for parity with the other `@templatical/import-*` packages — this converter does not currently produce it. |

```ts
console.log(report.summary);
// { total: 161, converted: 152, approximated: 9, htmlFallback: 0, skipped: 0 }

for (const entry of report.entries) {
  if (entry.status === 'approximated') {
    console.warn(`<${entry.sourceTag}> approximated:`, entry.note);
  }
}
// <mj-social> approximated: Icon size 35px is not one of 24/32/48px; resolved to "medium".
```

A `note` on an `approximated` entry always names the value it replaced, so diffing `report.entries` between two runs shows exactly what a migration changed. `report.warnings` carries the handful of document-level drops that don't attach to a single entry — a dropped document line-height, for instance, covered under "Things that don't map automatically" below.

## Conversion model

Topol's own JSON tree already speaks in MJML-shaped tags — `mj-section`, `mj-column`, `mj-text`, `mj-button`, and so on — even though Topol is not MJML itself. Its root node is `mj-global-style`, not MJML's `<mjml>`/`<mj-body>` pair, and a few tags pack information differently: a single `mj-social` node carries every icon as `<platform>-href` attributes, rather than nesting child `mj-social-element`s the way hand-written MJML does. Rich content sits in a `content` field beside each node's `attributes`, not inside it.

- **Topol** stores a design as this tree of tag-shaped nodes, plus a root-level `attributes` object holding per-tag and per-selector style defaults — the same role MJML's `<mj-attributes>` plays, flattened onto the root instead of nested under `<mj-head>`.
- **Templatical** stores templates as a JSON tree of typed blocks (`SectionBlock`, `ParagraphBlock`, etc.) and renders that tree to MJML at export time.

`@templatical/import-topol` walks the Topol tree, resolves each node's own attributes against the root's per-tag and per-selector defaults, and constructs the equivalent Templatical block. The tag-mapping table below is what it implements.

## Path 1 — Rebuild visually using your Topol export as a reference

For a handful of templates, rebuilding by hand next to your Topol export is often faster than installing a package:

1. Open your Topol design — the editor itself, or the JSON export — in one window.
2. Open the Templatical editor (or [the playground](https://play.templatical.com)) side-by-side.
3. Use Topol's own preview, or the `html` field from its API response, as your visual target.
4. Drag in the equivalent Templatical blocks (see the [mapping table](#topol-tag-mapping) below).
5. Copy text content directly. Re-host images via your media library.
6. Reproduce styling using Templatical's [design tokens](/guide/theming) instead of Topol's attribute defaults.

Most Topol templates port in 10–20 minutes once you've done one or two. For a larger batch, run `@templatical/import-topol` first and use this path only to finish off what it left `approximated` or as an `html-fallback` block.

## Path 2 — Use Templatical's renderer to verify your work

Once you have a template in Templatical — imported or rebuilt by hand:

```ts
import { renderToMjml } from '@templatical/renderer';

const mjml = await renderToMjml(content);
// Compile with your own MJML library and compare the result against
// Topol's own `html` field, from the same API response that carries `json`.
```

A visual comparison catches what the report can't. Several of the losses below — the section background image most visibly — are never read by the importer at all, so nothing in `report.entries` points at them.

## Topol tag mapping {#topol-tag-mapping}

| Topol tag | Templatical block | Notes |
|---|---|---|
| `mj-section` (with `mj-column` children) | `SectionBlock` with `columns` | Column widths come from each column's `width` percentage, or are distributed equally; a shape outside Templatical's five layouts resolves to the nearest one. |
| `mj-column` | Section column | Holds a list of nested blocks. |
| `mj-text` | `TitleBlock` or `ParagraphBlock` | A single heading wrapping the whole content becomes a `TitleBlock`; anything else becomes a `ParagraphBlock`. |
| `mj-button` | `ButtonBlock` | `href`, `background-color`, `color`, font, and `padding` as the block's outer spacing — its `margin` does not survive. |
| `mj-image` | `ImageBlock` | `src`, `alt`, `href`, `width`, padding. |
| `mj-gif` | `ImageBlock` | Same fields as `mj-image` — Templatical has no dedicated GIF block. |
| `mj-divider` | `DividerBlock` | `border-color`, `border-width`, `border-style`, padding. |
| `mj-spacer` | `SpacerBlock` | `height`. |
| `mj-social` | `SocialIconsBlock` | Each platform named in the node's `display` list with a matching `<platform>-href` becomes a `SocialIcon`. Topol packs every icon onto this one node's attributes, rather than nesting `mj-social-element` children the way hand-written MJML does. |
| Root `attributes` (plus `mj-container`'s `background-color`) | Template `settings` | A bare `:color` / `a:color` sets the document's text/link colour; per-selector defaults (`h1:font-family` and similar) seed a heading's font when the heading sets none itself. A node's own attribute always wins over these. |
| Anything else | `HtmlBlock` | No Templatical block exists for the tag; the raw node is preserved as JSON, flagged `html-fallback`. |

## Where the mapping is lossy

Every leaf tag in the mapping table above converts — Topol's own editor doesn't emit anything this importer treats as `html-fallback` in practice. Within that, a few conversions are approximations rather than exact matches, each recorded as an `approximated` entry with a `note` in `report.entries`:

- **Column geometry** — Templatical supports five column layouts (`1`, `2`, `3`, `2-1`, `1-2`). Topol allows any column count at any width, so a shape outside those five — most often four equal columns — resolves to the nearest layout, and any column past the third folds its content into the last one.
- **GIFs** — `mj-gif` imports as an `ImageBlock`, the same block `mj-image` produces. Templatical has no dedicated GIF block.
- **Heading levels** — `TitleBlock` supports levels 1 through 4. An `mj-text` wrapping a sole `<h5>` or `<h6>` clamps to level 4.
- **The `google` platform** — `SocialPlatform` has no `google` member, so a `google-href` maps to `website` instead. This is the common case, not an edge case: Topol's own social widget still writes `google-href` for Google+, a network retired years ago.
- **Social icon sizes** — `SocialIconsBlock` supports three sizes (24px, 32px, 48px). An `icon-size` outside those three resolves to the nearest one — and Topol's own default is 35px, so this is the common case for social blocks, not the exception.
- **Block IDs** — every imported block gets a freshly generated ID. IDs never appear anywhere in a Topol design, so nothing that keys off one — a Cloud comment thread, for example — survives an import.

## Things that don't map automatically

- **Section background images** — `SectionWrapper` carries only `backgroundColor`, `padding`, and `borderRadius`; Templatical has no background-image field on any block, and the renderer emits no `background-url`. A Topol section's `background-url` is never read, so a hero section keeps its background colour and loses its photo — and because the attribute is untouched, nothing in `report.entries` points at it. `full-width` and `layout` on the same `mj-section`, and `vertical-align` on `mj-column`, go the same way.
- **A button's margin** — `ButtonBlock`'s `styles.padding` is its only outer-spacing field. Topol writes both `padding` and `margin` on every `mj-button`; `padding` fills that field, and `margin` is dropped rather than folded in, which would double the vertical space. The button's inner label padding (`buttonPadding`) keeps Templatical's default too, since Topol has no separate attribute for it.
- **A paragraph's node-level color, font, and alignment** — `ParagraphBlock` accepts only `content` plus the block's `styles` (padding, background); there's no field for a node-level `color`, `font-family`, or `align`. An `mj-text` node that infers as a paragraph keeps those attributes only where the `content` HTML itself carries them inline — a bare node attribute is dropped silently, and the entry still reports `converted`, since there's nowhere in the block model to note the drop against. The asymmetry is worth knowing: a heading built from the same tag does honor a node's own `color` and `font-family` (`TitleBlock`, see the tag mapping table above), so a design that colours headings and paragraphs identically keeps that colour only on the headings after import.
- **Imported webfonts** — Templatical has no webfont-import concept. The design root's top-level `fonts` array lists the webfonts imported into the Topol project (one design measures `["\"Cabin\", sans-serif"]`; another carries two entries), and the array is dropped — text that depended on it renders in its fallback face, with no warning at runtime. This is a different field from `attributes.fonts`, a comma-separated fallback stack on the same root that the importer does read, feeding the template's default `fontFamily` when nothing more specific sets one.
- **Per-icon social details** — `SocialIcon` carries only `id`, `platform`, and `url`. A `mj-social` icon's per-platform `alt` text, `*-icon-color`, and `text-mode` have nowhere to go. The `alt` text is worth checking after import — it's the same accessibility gap as the images below, on a different block.
- **Document line-height** — Templatical has no document-level line-height setting. Topol's root-level default is dropped, and the importer pushes a note into `report.warnings` naming the value — the one loss on this list that surfaces at runtime.
- **Unrecognized tags** — a Topol node the importer doesn't handle explicitly becomes an `HtmlBlock` holding the raw node as JSON, not markup, since Topol nodes aren't markup. Every leaf tag Topol's own editor produces is handled explicitly, so this is a safety net for hand-edited or third-party JSON more than something a normal export triggers. Reimplement the tag as a [Templatical custom block](/guide/custom-blocks) for a native, editable equivalent.

::: tip
A freshly imported template commonly fails `@templatical/quality`'s accessibility rules on arrival, and the [Agent Skill](/guide/agent-skill)'s `validate` command exits non-zero on it. Images exported from Topol typically carry no `alt` text, and the importer copies that gap across faithfully — inventing a description would itself be an accessibility anti-pattern. Structural validation passes; the findings are about content. Add alt text to the imported images and the findings clear.
:::

## Further help

[Open a discussion](https://github.com/templatical/sdk/discussions) with a redacted snippet of your Topol design and what you're trying to achieve. We use these reports to improve `@templatical/import-topol`'s coverage.

---

# Migration from Unlayer

Source: https://docs.templatical.com/guide/migration-from-unlayer

The `@templatical/import-unlayer` package converts Unlayer design JSON (the output of `editor.saveDesign(...)` from `react-email-editor` or the Unlayer hosted editor) into Templatical's `TemplateContent` format.

::: warning
This package is in active development. Some content types and advanced features may not be fully supported yet. Test your converted templates before using them in production.
:::

## Installation

::: code-group

```bash [npm]
npm install @templatical/import-unlayer
```

```bash [pnpm]
pnpm add @templatical/import-unlayer
```

```bash [yarn]
yarn add @templatical/import-unlayer
```

```bash [bun]
bun add @templatical/import-unlayer
```

:::

### Without a build step (CDN)

You can also load it from a CDN:

```html
<script type="module">
  import { convertUnlayerTemplate } from 'https://cdn.jsdelivr.net/npm/@templatical/import-unlayer/+esm';
  // ...then convert as shown in Usage below
</script>
```

## Usage

```ts
import { convertUnlayerTemplate } from '@templatical/import-unlayer';

// Load your Unlayer design JSON (whatever editor.saveDesign returned)
const res = await fetch('/api/unlayer-templates/123');
const unlayerJson = await res.json();

// Convert to Templatical format
const { content, report } = convertUnlayerTemplate(unlayerJson);

// Use in the editor
const editor = await init({
  container: '#editor',
  content,
});

// Check the conversion report for any issues
console.log(report);
```

Each `report.entries` item is one source module:

| Status | Meaning |
|---|---|
| `converted` | Mapped to a Templatical block with no loss of fidelity. |
| `approximated` | Mapped, with a clamp or flatten — `note` states what changed. |
| `html-fallback` | No block equivalent; original markup is an `HtmlBlock`. |
| `skipped` | No output (forms, and anything the converter refuses). |

The design JSON Unlayer's hosted editor and `editor.saveDesign()` emit is the input. A compiled HTML export is a different envelope — use [`@templatical/import-html`](/guide/migration-from-html).

The function returns an `ImportResult` with:
- `content` — the converted `TemplateContent` ready for the editor
- `report` — a conversion report with the status of each content node (`converted`, `approximated`, `html-fallback`, or `skipped`)

## Block Mapping

Unlayer content types map to Templatical equivalents:

| Unlayer Content | Templatical Block | Status |
|---|---|---|
| Text | `paragraph` | Converted |
| Heading | `title` | Converted |
| Image | `image` | Converted |
| Button | `button` | Converted |
| Divider | `divider` | Converted |
| Spacer | `spacer` | Converted |
| Html | `html` | Converted |
| Menu | `menu` | Approximated (styles may differ) |
| Social | `social` | Converted |
| Video | `video` | Converted |
| Timer | `html` | HTML fallback (rebuild manually) |
| Form | — | Skipped |

Unknown content types are converted to HTML blocks as a fallback.

## Column Layout Conversion

Unlayer organizes content into rows with columns whose widths come from a `cells` weight array. These map to Templatical's `SectionBlock` with the appropriate `ColumnLayout`:

| Unlayer cells | Templatical Layout |
|---|---|
| `[1]` (single column) | flattened — no section wrapper |
| `[1, 1]` (equal halves) | `'2'` |
| `[1, 1, 1]` (equal thirds) | `'3'` |
| `[1, 2]` | `'1-2'` |
| `[2, 1]` | `'2-1'` |
| 4+ cells | flattened to single column with a warning |

Cell ratios that don't match a standard layout are mapped to the closest available one.

## Template Settings

Global template settings are converted where possible:

- **Width** — Unlayer `body.values.contentWidth` maps to `settings.width`
- **Background color** — `body.values.backgroundColor` is preserved; row-level backgrounds carry over to the corresponding `SectionBlock`
- **Font family** — `body.values.fontFamily.value` carries over to `settings.fontFamily`

## Known Limitations

- **Custom fonts** — Unlayer custom font declarations are not automatically imported. Add them manually via the `fonts` config option.
- **Display conditions / dynamic content** — Unlayer's conditional content syntax has no direct equivalent and is dropped during conversion. Use Templatical's [display conditions](/guide/display-conditions) to recreate them.
- **Custom modules / paid-tier blocks** — Unlayer custom blocks are converted to placeholder HTML blocks. Recreate them as a [custom block](/guide/custom-blocks) if reusable.
- **Forms** — Unlayer form blocks are skipped. Most email clients block form submission for security reasons; rebuild the call-to-action as a button linking to a hosted form.
- **Timers / countdowns** — Imported as a placeholder HTML block. Do not emit `type: "countdown"`: that block needs Cloud's server-side GIF and the OSS renderer cannot produce it. Recreate as a static title or paragraph (the date, or "X days to go"), or keep the HTML placeholder.
- **AMP for Email** — not currently supported in Templatical.

## Verifying Converted Templates

After conversion, review the output in the editor to check for:

1. Missing images (re-upload or update URLs if needed)
2. Font rendering (add custom fonts to the editor config)
3. Column proportions (adjust layouts if the automatic mapping doesn't match)
4. Spacing and padding (fine-tune in the block settings panel)

---

# Preview Rendering

Source: https://docs.templatical.com/guide/preview-rendering

A template is full of things that aren't content: <code v-pre>{{first_name}}</code>, <code v-pre>{% if plan_name == 'pro' %}</code>. The editor has to show *something* for them, and what it shows depends on how much you've told it.

There are three layers, each one more realistic than the last. All of them apply **only to preview surfaces** — the editor's preview mode and the test-email dialog. The editing canvas always shows the tag you inserted, so you never edit text you didn't write.

| Layer | Configure | A preview shows |
| --- | --- | --- |
| **Labels** | nothing — always on | `First Name`, highlighted |
| **Sample values** | `MergeTag.sample` | `Ada`, as ordinary text |
| **Resolved data** | `resolvePreview` | whatever your backend returns, logic evaluated |

Later layers win. Set a `sample` and previews use it instead of the label; configure `resolvePreview` and it supersedes samples entirely.

## Labels (the default)

With `mergeTags.tags` configured, a tag renders as its human-readable `label` with a highlight, so the template reads as prose instead of tokens. Logic tags render as keyword badges — **IF**, **ENDIF**, **FOR**. See [Merge Tags](/guide/merge-tags) and [Logic tag highlighting](/guide/merge-tags#logic-tag-highlighting).

This answers *"which field goes here?"*. It doesn't tell you what the email will look like.

## Sample values

Give a tag a `sample` and previews render that instead of its label:

```ts
mergeTags: {
  tags: [
    { label: 'First Name', value: '{{first_name}}', sample: 'Ada' },
    { label: 'Plan', value: '{{plan_name}}', sample: 'Pro' },
  ],
}
```

Setting `sample` is the whole opt-in — there is no flag alongside it. See [Merge Tags](/guide/merge-tags#sample-values) for the field itself.

### The Sample / Label switch

A **Sample / Label** switch floats at the top of the canvas whenever a preview is showing, so a user can flip between the realistic view and the field names. The choice lasts for the session.

It renders **only when at least one configured tag declares a `sample`**, and previews default to Sample view only in that case. Configure none and the editor behaves exactly as before — Label view, no switch — so this is invisible until you opt in.

### The highlight follows the tag, not the view

| | In Sample view | In Label view |
| --- | --- | --- |
| Tag **with** a `sample` | the sample, as ordinary text — no highlight | its label, highlighted |
| Tag **without** one | its label, **highlighted** | its label, highlighted |

So a partly-configured template reads naturally where you've supplied data and stays visibly dynamic where you haven't — the remaining highlights double as a list of tags still missing a sample.

**Samples can't evaluate logic.** Substituting a value is not the same as taking a branch, so `{% if %}` … `{% endif %}` blocks stay visible as badges no matter how many samples you set. That ceiling is what the next layer exists for.

## Resolved data with `resolvePreview`

Pass a callback and your own backend resolves the template:

```ts
import { init } from '@templatical/editor';

await init({
  container: '#editor',
  resolvePreview: async ({ content, recipient }) => {
    const res = await fetch('/api/resolve-preview', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content, recipient }),
    });
    if (!res.ok) throw new Error('Could not resolve the preview');
    return res.json(); // a TemplateContent
  },
});
```

### Why a callback rather than a built-in engine

The editor **recognises** tags; it never **evaluates** them. `mergeTags.syntax` is a pair of regex patterns — one for value tags, one for logic tags — and matching is all the editor needs in order to highlight them. It is also all it does: logic tags pass through to the rendered MJML untouched, and whatever sends the email evaluates them there.

Evaluating a branch needs three things the editor doesn't have — your data, your template language, and the rules that decide what a branch means. And `syntax` accepts **your own regexes**, so the set of languages Templatical can be pointed at isn't bounded; there is no engine we could ship that would cover it.

So the hook hands the template to the system that already holds all three. Whatever renders your sends can render your previews.

### What you receive

```ts
interface PreviewResolveContext {
  content: TemplateContent; // the template as it currently stands, as a copy
  recipient?: string;       // present only where the surface has one
}
```

`content` is a copy — mutating it cannot affect the editor. When `layout` is set, `content` is the composed document. See [Layout](/guide/layout). `recipient` is present in the test-email dialog and absent in the editor's preview mode; treat its absence as *"no particular recipient"* and return something renderable anyway.

Return a `TemplateContent`. Anything else is treated as a failure (see below).

### When it runs

- **Immediately** when a preview opens. There is nothing to coalesce at that moment, so it is not debounced — the skeleton is up in the same frame as the click.
- **Debounced 500ms** on subsequent re-resolves, which today means the test-email recipient changing. Rapid changes collapse into one call.
- **Never while editing.** An editor that never enters preview mode never calls your hook.

A **first** resolve shows a skeleton. A re-resolve keeps the previous result on screen instead of flashing a skeleton over content that is already correct.

Slow answers are discarded when a newer request supersedes them, so switching recipient twice cannot land the first answer last.

### When it fails

If your callback rejects — or returns something that isn't a `TemplateContent` — the preview falls back to the **unresolved** template and says so inline. A resolver outage degrades the preview; it never blanks or breaks it.

Failures are **not** routed to `config.onError`. A degraded preview is visible to the user and non-fatal, and reporting it there would read as more severe than it is.

### It is display-only

Resolved content reaches preview surfaces and nothing else. It is never written to editor state, never returned by `getContent()`, never sent by the test-email feature, and never exported — those always carry the real tokens.

That is the guarantee that makes the hook safe to be creative with: nothing you return can reach a recipient.

## Use cases

### Preview as a specific recipient

The most direct one. The test-email dialog passes the selected address as `recipient`, so the preview shows what *that person* will receive — with their name, their plan, their branches taken.

```ts
resolvePreview: async ({ content, recipient }) => {
  const data = recipient
    ? await fetchSubscriber(recipient)
    : await fetchSampleSubscriber();
  return renderWithMyEngine(content, data);
},
```

### Let the user pick an example audience

Because the callback is `async`, you can open **your own UI** inside it and resolve once the user has chosen. If you have several kinds of subscriber — free vs pro, trial vs churned, EU vs US — this lets someone flip between them and see each version of the email.

```ts
resolvePreview: async ({ content }) => {
  const audience = await openMyAudiencePicker();
  if (!audience) {
    // Dismissed. Throwing shows the unresolved template *and* the inline note;
    // returning `content` unchanged shows it with no note. Pick one.
    return content;
  }
  return renderWithMyEngine(content, audience.data);
},
```

While your dialog is open the preview shows its skeleton — the preview is not ready yet.

### Evaluate display conditions

[Display conditions](/guide/display-conditions) let a block be shown only when some rule holds. In the editor a user *simulates* that by clicking the block's filter icon — nothing checks the rule against data. A resolver can do it properly: omit blocks whose condition doesn't hold for the recipient, and the preview shows the real variation.

The manual filter steps aside while your resolver is showing, along with its restore button — you evaluated the conditions against real data, so a hand-toggled hide would veto the answer that was asked for. Nothing is discarded: the user's hidden blocks are back when they leave the preview.

### Reuse the engine that renders your sends

Something already renders your sends — your sending platform, your own service, a template engine on your server. It holds the data and speaks your template language, so posting the template to it and returning its output makes the preview agree with the delivered email by construction instead of approximating it in the browser.

It is also the only route when your template language can't be evaluated client-side at all, which includes any custom `syntax` you configure.

### Show what your platform appends at send time

If your application adds something to every email after it leaves the editor — a branding badge, a legal footer, an unsubscribe line, a logo header — the author never sees it while composing, so nothing tells them how the finished message ends. Have your backend append the same blocks it will append for real, and the preview shows the whole email.

```ts
resolvePreview: async ({ content, recipient }) => {
  // Your backend resolves the template and appends the same chrome it
  // adds at send time, so the unsubscribe link is that recipient's real one.
  const res = await fetch("/api/preview", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ template: content, recipient }),
  });
  return res.json();
},
```

Return a whole `TemplateContent`, not just its `blocks`. An object carrying only `blocks` is accepted — that is all the shape check looks for — but it arrives without `settings`, and the preview loses the template's width, background and fonts. The appended blocks otherwise render through the canvas like any others, picking up the template's fonts and link styles, the dark-mode preview and the viewport switch.

Appending server-side is what keeps the two in step. The chrome is defined once, in the code path that appends it for real, so the preview cannot drift from the delivered email the way a second copy in the browser would. It also means the values are resolved rather than approximated: a real unsubscribe URL for `recipient`, not a token.

None of it can double up. Resolved content reaches preview surfaces only, so what your endpoint appends is never saved by `getContent()`, never exported by `toMjml()`, and never part of what the test-email feature sends — your send pipeline stays the only thing that appends for real.

### Pull in live data

Prices, stock levels, a personalised product grid. Anything the template references but doesn't store can be fetched at preview time, so the preview reflects reality rather than whatever was authored.

## Playground

The [playground](https://play.templatical.com) wires a fake resolver on the **Welcome Email** template only — it substitutes values and evaluates that template's `{% if plan_name == … %}` branches after a short delay, so you can watch the skeleton and see the conditional collapse to just the branch that applies.

Every other template leaves it off, so they demonstrate the Sample / Label switch instead. Both templates describe which feature they're showing in their "what's on this template" panel.

## See also

- [Merge Tags](/guide/merge-tags) — configuring tags, labels and `sample` values
- [Logic Tags](/guide/logic-tags) — inserting and highlighting control flow
- [Display Conditions](/guide/display-conditions) — condition simulation, and evaluating conditions for real via this hook
- [Test Emails](/backend/test-email) — the dialog whose preview resolves per recipient
- [Editor API](/api/editor) — the `resolvePreview` and `mergeTags` config reference

---

# Programmatic Templates

Source: https://docs.templatical.com/guide/programmatic-templates

Most users will design templates visually in the drag-and-drop editor. But sometimes you need to create templates in code -- seeding default content, building templates from API data, or generating emails programmatically on the server.

Templatical provides factory functions for every block type. Use them to build templates in code, then pass them to `init()` or render them directly with the renderer.

## Blank template

```ts
import { createDefaultTemplateContent } from '@templatical/types';

const content = createDefaultTemplateContent();
// { blocks: [], settings: { width: 600, backgroundColor: '#ffffff',
//   textColor: '#1a1a1a', linkUnderline: true, fontFamily: 'Arial', locale: 'en' } }
```

`createDefaultTemplateContent()` accepts an optional font family string:

```ts
const content = createDefaultTemplateContent('Georgia, serif');
```

## Building a template

Every block type has a corresponding `create*Block()` function. Each accepts an optional partial object to override defaults. All factory functions auto-generate a unique `id` for each block.

```ts
import {
  createDefaultTemplateContent,
  createTitleBlock,
  createParagraphBlock,
  createImageBlock,
  createButtonBlock,
  createDividerBlock,
} from '@templatical/types';

const content = createDefaultTemplateContent();

content.blocks = [
  createTitleBlock({
    content: '<h1 style="text-align: center;">Welcome aboard</h1>',
    level: 1,
  }),
  createImageBlock({
    src: 'https://example.com/hero.jpg',
    alt: 'Welcome hero image',
    width: 'full',
  }),
  createDividerBlock(),
  createParagraphBlock({
    content: '<p>Thanks for signing up. Here is what happens next.</p>',
  }),
  createButtonBlock({
    text: 'Get Started',
    url: 'https://example.com/dashboard',
    backgroundColor: '#1a73e8',
    textColor: '#ffffff',
    borderRadius: 6,
  }),
];
```

## Block factory reference

### Title

```ts
createTitleBlock({
  content: '<h1>Welcome, {{name}}!</h1>',
  level: 1,
  textAlign: 'center',
})
```

### Paragraph

```ts
createParagraphBlock({
  content: '<p>Thanks for signing up. Here is what happens next.</p>',
})
```

### Image

```ts
createImageBlock({
  src: 'https://cdn.example.com/hero.png',
  alt: 'Hero banner',
  width: 560,
  linkUrl: 'https://example.com',
})
```

### Button

```ts
createButtonBlock({
  text: 'Get Started',
  url: 'https://example.com/signup',
  backgroundColor: '#6366f1',
  borderRadius: 8,
})
```

### Divider

```ts
createDividerBlock({
  lineStyle: 'dashed',
  color: '#e5e7eb',
  thickness: 2,
})
```

### Spacer

```ts
createSpacerBlock({ height: 40 })
```

### HTML

```ts
createHtmlBlock({
  content: '<div style="text-align:center;">Custom markup</div>',
})
```

### Social Icons

```ts
createSocialIconsBlock({
  iconStyle: 'circle',
  iconSize: 'large',
  icons: [
    { id: crypto.randomUUID(), platform: 'twitter', url: 'https://x.com/acme' },
    { id: crypto.randomUUID(), platform: 'github', url: 'https://github.com/acme' },
  ],
})
```

### Menu

```ts
createMenuBlock({
  items: [
    { id: crypto.randomUUID(), text: 'Home', url: 'https://example.com', openInNewTab: false, bold: false, underline: false },
    { id: crypto.randomUUID(), text: 'Blog', url: 'https://example.com/blog', openInNewTab: false, bold: false, underline: false },
    { id: crypto.randomUUID(), text: 'Docs', url: 'https://docs.example.com', openInNewTab: true, bold: false, underline: false },
  ],
  separator: '-',
})
```

### Table

```ts
createTableBlock({
  hasHeaderRow: true,
  rows: [
    { id: crypto.randomUUID(), cells: [{ id: crypto.randomUUID(), content: 'Plan' }, { id: crypto.randomUUID(), content: 'Price' }] },
    { id: crypto.randomUUID(), cells: [{ id: crypto.randomUUID(), content: 'Starter' }, { id: crypto.randomUUID(), content: '$9/mo' }] },
    { id: crypto.randomUUID(), cells: [{ id: crypto.randomUUID(), content: 'Pro' }, { id: crypto.randomUUID(), content: '$29/mo' }] },
  ],
})
```

### Video

```ts
createVideoBlock({
  url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
  thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
  alt: 'Product demo video',
})
```

### Countdown

`createCountdownBlock` builds a countdown block, but countdown GIFs need Cloud or a `blockRenderers.countdown` override — the OSS renderer emits a placeholder.

### Section

```ts
createSectionBlock({
  columns: '2',
  children: [
    [createParagraphBlock({ content: '<p>Left column</p>' })],
    [createImageBlock({ src: 'https://cdn.example.com/photo.jpg' })],
  ],
})
```

The `columns` property accepts: `'1'` (single), `'2'` (two equal), `'3'` (three equal), `'2-1'` (two-thirds / one-third), `'1-2'` (one-third / two-thirds). See [Sections and Columns](/guide/sections-and-columns) for full details.

### Custom

`createCustomBlock` takes a `CustomBlockDefinition` (not a partial block). It generates field values from the definition's field defaults. See [Custom Blocks](/guide/custom-blocks) for defining custom block types.

## Utilities

### Generic factory

Create any block by type string:

```ts
import { createBlock } from '@templatical/types';

const block = createBlock('title'); // TitleBlock with defaults
```

### Cloning

Deep-clone a block with a new ID:

```ts
import { cloneBlock } from '@templatical/types';

const copy = cloneBlock(existingBlock);
// copy.id !== existingBlock.id
```

### Type guards

Narrow a `Block` union to a specific type:

```ts
import { isTitle, isParagraph, isImage, isButton, isSection } from '@templatical/types';

if (isTitle(block)) {
  console.log(block.level); // TypeScript knows this is TitleBlock
}

if (isParagraph(block)) {
  console.log(block.content); // TypeScript knows this is ParagraphBlock
}

if (isImage(block)) {
  console.log(block.src);
}
```

Every block type has a corresponding guard: `isTitle()`, `isParagraph()`, `isImage()`, `isButton()`, `isDivider()`, `isSpacer()`, `isHtml()`, `isSocialIcons()`, `isMenu()`, `isTable()`, `isVideo()`, `isSection()`, `isCountdown()`, `isCustomBlock()`.

## Template settings

Template settings control the global properties of the email:

```ts
const content = createDefaultTemplateContent();

content.settings.width = 640;
content.settings.backgroundColor = '#f5f5f5';
content.settings.fontFamily = 'Helvetica, Arial, sans-serif';
content.settings.preheaderText = 'Your weekly digest is here';
content.settings.locale = 'en';
content.settings.direction = 'ltr';
```

| Setting | Type | Description |
|---|---|---|
| `width` | `number` | Email width in pixels |
| `backgroundColor` | `string` | Outer background color |
| `textColor` | `string` | Default text color inherited by all text blocks; per-block colors override. Defaults to `#1a1a1a` |
| `linkColor` | `string` | Document link color applied to every link (rich-text and menu); links inherit the text color when unset. Optional |
| `linkUnderline` | `boolean` | Underline body (rich-text) links. Defaults to `true` |
| `fontFamily` | `string` | Default font stack |
| `preheaderText` | `string` | Preview text shown in inbox list |
| `locale` | `string` | BCP-47 content language (`<html lang>`) |
| `direction` | `'ltr' \| 'rtl'` | Writing direction. Unset follows the content language |

For default values and how to customize them, see [Block & Template Defaults](/guide/defaults).

## Loading saved content

Pass previously saved JSON back to the editor:

```ts
const res = await fetch('/api/templates/123');
const saved = await res.json();

const editor = await init({
  container: '#editor',
  content: saved,
});
```

You can also update content after initialization:

```ts
editor.setContent(newContent);
```

## Next steps

- [Block Types](/guide/blocks) -- properties reference for every built-in type.
- [How Rendering Works](/getting-started/how-rendering-works) -- the JSON → MJML pipeline.
- [Custom Blocks](/guide/custom-blocks) -- define your own block types.

---

# Sections and Columns

Source: https://docs.templatical.com/guide/sections-and-columns

The `SectionBlock` is the layout primitive in Templatical. Every multi-column layout is built by creating a section and placing blocks inside its columns.

## Column layouts

::: tip Email best practice
Stick to 1-2 columns for most emails. Three-column layouts become cramped on mobile when columns stack vertically, and some email clients handle 3-column layouts inconsistently.
:::

The `columns` property accepts one of five layout presets:

| Value | Description | Column widths |
|-------|-------------|---------------|
| `'1'` | Single column | 100% |
| `'2'` | Two equal columns | 50% / 50% |
| `'3'` | Three equal columns | 33% / 33% / 33% |
| `'2-1'` | Two-thirds / one-third | 66% / 33% |
| `'1-2'` | One-third / two-thirds | 33% / 66% |

```ts
type ColumnLayout = '1' | '2' | '3' | '2-1' | '1-2';
```

## Creating sections

Use `createSectionBlock` from `@templatical/types`:

```ts
import {
  createSectionBlock,
  createTitleBlock,
  createParagraphBlock,
  createImageBlock,
} from '@templatical/types';

// Empty two-column section
const section = createSectionBlock({ columns: '2' });

// Section with pre-populated columns
const hero = createSectionBlock({
  columns: '1-2',
  children: [
    [createImageBlock({ src: 'https://cdn.example.com/logo.png', width: 120 })],
    [
      createTitleBlock({ content: '<h1>Welcome</h1>', level: 1 }),
      createParagraphBlock({ content: '<p>Get started in minutes.</p>' }),
    ],
  ],
});
```

## The children array

`children` is a `Block[][]` -- an array of arrays. Each inner array represents the blocks inside one column, ordered left to right.

```ts
// For a '2' layout:
section.children = [
  [blockA, blockB],  // Left column
  [blockC],          // Right column
];

// For a '3' layout:
section.children = [
  [blockA],  // Left
  [blockB],  // Center
  [blockC],  // Right
];
```

The length of `children` must match the number of columns in the chosen layout. When you change `columns`, the editor automatically adjusts `children` -- merging excess columns into the last column or adding empty arrays for new columns.

## Adding blocks to columns

To programmatically add a block to a specific column:

```ts
// Add a button to the second column (index 1)
section.children[1].push(
  createButtonBlock({
    text: 'Learn More',
    url: 'https://example.com/docs',
  }),
);
```

## Nesting

Sections cannot be nested inside other sections. Each section sits at the top level of the template's block list. Within a column, you can place any non-section block type: titles, paragraphs, images, buttons, tables, custom blocks, and so on.

## Responsive behavior

On desktop, columns render side-by-side at their defined widths. On smaller screens, columns stack vertically in source order (left column on top) — MJML's default.

`stackOnMobile` opts out. Omit it or set `true` to keep stacking. `false` wraps the columns in `<mj-group>` so they stay side by side on mobile. The section toolbar exposes the toggle on multi-column sections. A one-column section never emits `mj-group`.

```ts
const row = createSectionBlock({ columns: '2', stackOnMobile: false });
```

You can use the `visibility` property on individual blocks within columns to show or hide content per breakpoint:

```ts
const block = createParagraphBlock({
  content: '<p>Desktop only sidebar content</p>',
});

block.visibility = {
  desktop: true,
  mobile: false,
};
```

See [Styling](/guide/styling) for more on block visibility.

## Section-level styles

Sections support the same `BlockStyles` as other blocks. Common use cases include setting a background color or padding on the entire row:

```ts
const section = createSectionBlock({ columns: '1' });

section.styles = {
  backgroundColor: '#f8fafc',
  padding: { top: 32, right: 24, bottom: 32, left: 24 },
};
```

Sections also accept a `borderRadius` (in px, a section-specific property) for rounded corners — useful for a card-style section on a contrasting background:

```ts
const card = createSectionBlock({ columns: '1', borderRadius: 12 });

card.styles.backgroundColor = '#ffffff';
```

### Outer frame (wrapper)

To frame a section in a full-width colored band — e.g. a white card sitting on a blue background — set `wrapper`. It renders an `mj-wrapper` around the section, with its own background, padding, and corner radius:

```ts
const card = createSectionBlock({ columns: '1', borderRadius: 12 });
card.styles.backgroundColor = '#ffffff';

card.wrapper = {
  backgroundColor: '#1e3a8a',
  padding: { top: 24, right: 24, bottom: 24, left: 24 },
  borderRadius: 16,
};
```

The wrapper's padding is what makes the band show around the section. In the editor, enable it from the **Wrapper** panel in the section toolbar. The wrapper is a styling layer, not a container — sections still cannot be nested. An embedder-owned card around the authored sections is a [layout](/guide/layout) overlay.

---

# Shadow DOM

Source: https://docs.templatical.com/guide/shadow-dom

Templatical mounts inside a [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM) by default. The shadow boundary isolates the editor's chrome, canvas, and rich-text content from your host page's CSS — even global resets like `* { color: red !important }` cannot cascade past it.

This page is the canonical reference for the isolation model. If you just want to theme the editor, jump to the [theming guide](./theming).

## Isolation

When you call `init()` or `initCloud()` and pass a container element, Templatical:

1. Calls `container.attachShadow({ mode: 'open' })` to create a shadow root on your element.
2. Mounts the Vue app inside the shadow root instead of replacing the container's children directly.
3. Adopts a single shared `CSSStyleSheet` containing every Tailwind utility, every SFC `<style>` block, and `styles/index.css` via `adoptedStyleSheets`.

All editor styles live inside the shadow root, so they cannot leak out. All host-page styles stop at the shadow boundary, so they cannot leak in. The mode is **`open`** — `container.shadowRoot` is non-null, and DevTools can fully inspect the tree.

## Default behavior

You get the boundary for free with no configuration:

```ts
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";

const editor = await init({
  container: "#editor",
  // shadowDom: true is the default — omit unless you want light DOM
});
```

After mount, the DOM looks like this:

```html
<div id="editor">
  #shadow-root (open)
  <link rel="stylesheet" href="…" />
  <!-- adopted stylesheet -->
  <div class="tpl tpl-editor-host">
    <!-- editor chrome, sidebars, canvas, toolbars -->
  </div>
</div>
```

The editor's `tpl:` Tailwind prefix and `.tpl-*` SDK classes are still in use under the hood, but the shadow boundary makes them functionally redundant for collision protection. They remain in place so the [opt-out mode](#opt-out-shadowdom-false) keeps working.

## Purpose

The `tpl:` prefix only protects one direction: editor utilities can never collide with host classes. It does **nothing** to stop the other direction — a host-page rule like `p { font-family: Comic Sans }` will cascade into every paragraph inside the editor, including ones inside the canvas preview.

Shadow DOM is the only standards-based way to block that cascade. The same approach is used by Stripe Elements, Intercom widgets, and most embeddable third-party UIs. See [issue #70](https://github.com/templatical/sdk/issues/70) for the original report.

## Default mode

Shadow DOM guarantees no style leaks between the host page and the editor UI — host CSS cannot cascade into the editor, and editor CSS cannot bleed into your app. Templatical enables that guarantee by default, rather than as an opt-in, because the conditions that make it necessary apply to nearly every host page:

- **Global resets, design systems, and framework preflight are everywhere.** Tailwind, Bootstrap, Material UI, Chakra, Mantine — every modern stack ships rules like `* { box-sizing: border-box }` and `body { font-family: … }`. Without the shadow boundary, every editor element would inherit whatever your host page declares.
- **The canvas renders email-like content, where typography and spacing matter most.** A host `body { font-family: Comic Sans }` cascading into the preview corrupts every template before it is exported.
- **Each editor on the page gets its own scope.** With multiple editors mounted side by side (e.g. draft + preview, A/B comparison), each shadow root isolates theming and host targeting per instance — host overrides on one don't reach the other.
- **Form controls in toolbars and dialogs would inherit host styling.** Text inputs, select dropdowns, and buttons would render with the host page's padding, fonts, and focus rings — different per consumer.

## Trade-offs

What you give up by mounting inside a shadow root:

- **Host-side `document.querySelector` cannot see editor internals.** Walk the shadow root: `container.shadowRoot.querySelector(...)`.
- **Browser minimums bump up.** The adopted-stylesheet path needs Chrome 80+, Edge 80+, Firefox 101+, and Safari 16.4+. If you need older Firefox or Safari, [opt out](#opt-out-shadowdom-false).
- **The container must be eligible to host a shadow root.** The HTML spec restricts `attachShadow()` to a specific element list — `<div>`, `<span>`, `<section>`, `<article>`, `<aside>`, `<header>`, `<footer>`, `<main>`, `<nav>`, `<p>`, `<blockquote>`, and custom elements with a hyphenated name. `<table>`, `<tr>`, `<td>`, `<form>`, `<input>`, `<button>`, `<select>`, list elements (`<ul>`, `<ol>`, `<li>`), `<iframe>`, and replaced elements (`<img>`, `<video>`, etc.) are not allowed. Use a `<div>`.
- **Fonts are the one intentional global escape.** Web fonts inside shadow roots are unreliable across browsers (Safari's `@font-face` resolution from inside a shadow root has historically been broken). Templatical's font loader still appends `<link>` tags to `document.head` so font requests resolve at document level. This is the only side effect on the host page.

## Theming via `:host`

Host-page CSS can set CSS variables on the container, and the values inherit across the shadow boundary into the editor. Templatical defines every design token as `var(--tpl-user-<name>, <default>)`, so any `--tpl-user-*` override on the container (or any ancestor) wins over the default.

```css
/* Override the editor's primary color from the host page */
#editor {
  --tpl-user-primary: oklch(65% 0.2 280);
  --tpl-user-primary-hover: oklch(58% 0.2 280);
  --tpl-user-primary-light: oklch(94% 0.05 280);

  /* Dark-mode equivalents live in the --tpl-user-dark-* namespace */
  --tpl-user-dark-primary: oklch(75% 0.16 280);
}
```

Set the variables on the container that you pass to `init()` (or any ancestor) and the editor inherits them.

See the [theming guide](./theming) for the full token list and dark-mode handling.

## Opt-out: `shadowDom: false`

When you'd opt out:

- Your host integration relies on light-DOM `document.querySelector` to reach into the editor (for testing harnesses, third-party widget wiring on custom blocks, etc.).
- You need to support Firefox 80–100 or Safari 14–16.3 — the `adoptedStyleSheets` API is missing there.
- Your container is locked to an element type that cannot host a shadow root (table cell, form field, etc.).

```ts
const editor = await init({
  container: "#editor",
  shadowDom: false,
});
```

What you lose:

- Host CSS cascades into the editor again. The `tpl:` prefix protects class-name collisions; the hand-rolled `.tpl` reset block in `styles/index.css` protects font, button, and form defaults, but **arbitrary host rules targeting bare tags (`p`, `a`, `input`) will bleed in**.

What still works:

- Every other feature is identical: same blocks, same i18n, same cloud features, same API surface. The boundary is the only change.

### Hardening light-DOM mode against host CSS

If you opt out of shadow DOM, mount the editor inside a container that explicitly resets inherited host declarations before they reach the editor. The editor's built-in `.tpl` reset covers chrome font, button, scrollbar, and focus styles — but host rules that target bare tags (`p`, `a`, `input`, `*`) still cascade into the canvas in light-DOM mode.

The simplest mitigation is one CSS line on the container:

```css
#editor {
  /* Nullify author-layer host styles on this boundary so the editor's
     own .tpl stylesheet is the only thing that applies inside. */
  all: revert-layer;
}
```

`all: revert-layer` is the most surgical option — it strips author-layer styles (host CSS, framework resets, CSS-in-JS injections) on the container while preserving the browser's user-agent defaults. The editor's own scoped styles inside `.tpl` then apply normally.

Other options:

- **Scope aggressive global rules.** Rewrite `* { … }` or bare-tag rules so they don't match descendants of the editor container — e.g. `.my-app:not(#editor *) p { … }`.
- **Reset on a wrapper that excludes the editor.** Apply your CSS reset to a wrapper that contains your app chrome but NOT the editor container.
- **Avoid `!important` on tag-selector rules.** `p { font: … !important }` is the worst offender for light-DOM mode because it overrides the editor's own font-family declarations.

In default shadow-DOM mode none of this is necessary — the boundary handles it for you. The `all: revert-layer` line is harmless to add even when `shadowDom: true` (it applies to the container, not the editor's internals).

## Targeting editor internals from the host

In shadow DOM mode, the container exposes a non-null `shadowRoot`. Pierce it explicitly:

```ts
const container = document.querySelector("#editor");
const block = container.shadowRoot?.querySelector('[data-block-id="abc-123"]');
```

Built-in browser tooling that walks light DOM (e.g. `document.querySelectorAll('.tpl-block')`) returns an empty set in shadow mode — you must enter the shadow root explicitly. Custom-block integrations that need host-side access should either:

1. Use `container.shadowRoot.querySelector(...)` (works without opt-out), or
2. Pass `shadowDom: false` if the integration cannot be rewritten.

## Debugging tips

**DevTools.** The shadow root is `open`, so the Elements panel renders it inline with a `#shadow-root (open)` label. Click to expand. Computed styles, layout, and inspect-on-hover all work normally.

**Playwright.** Locators built with `page.locator()` and `page.getByTestId()` automatically pierce open shadow roots. No `>>>` shadow-pierce selector is needed for `data-testid` matches. CSS selectors that walk shadow boundaries explicitly (`#editor >>> .tpl-canvas`) also work.

**ProseMirror selection.** The TipTap rich-text editor uses ProseMirror under the hood. ProseMirror 1.41+ detects shadow roots automatically and reads selection via `view.root.getSelection()`. No additional wiring is needed.

**Host stylesheet experiments.** To prove the boundary is doing its job, open DevTools on the playground (`https://play.templatical.com`) and inject:

```js
const style = document.createElement("style");
style.textContent =
  "* { color: red !important; font-family: Comic Sans !important; }";
document.head.appendChild(style);
```

The host page goes red and ugly; the editor canvas and chrome remain untouched.

## FAQ

**Do web fonts loaded via `fonts` config work?**
Yes. The font loader explicitly injects `<link>` tags into `document.head` instead of the shadow root because `@font-face` resolution from inside shadow roots is unreliable. This is the one intentional global side effect.

**Does printing work?**
Yes. Adopted stylesheets honor `@media print` rules, and the editor's print layout is unaffected by the shadow boundary.

**Does TipTap text selection work across the shadow boundary?**
Yes. ProseMirror-view 1.41+ uses `view.root` (the shadow root in shadow mode) for selection APIs.

**Can I mount more than one editor on the page?**
Yes. Each `init()` call attaches a shadow root to its own container, and the per-container instance map keeps state isolated. Two editors on the same page do not share content, history, or selection state.

**Does the editor work inside an iframe?**
Yes. The iframe's `document` becomes the host, and the shadow root attaches as usual.

**Does CSS-in-JS (styled-components, emotion) from the host affect the editor?**
No. Host-side runtime style injection lands in `document.head`, which sits outside the shadow boundary.

**Does the host's Tailwind preflight reset the editor's typography?**
No. Tailwind preflight is global, so it lives in `document.head`, which the shadow boundary blocks.

**What if my framework's CSS reset (e.g. Bootstrap, Foundation) is present?**
Same answer — anything in `document.head` (or `document.body` `<style>` tags) is blocked.

## See also

- [Theming](./theming) — full design token list, dark mode, `:host` override patterns
- [API reference](../api/editor) — `shadowDom` config field and container element requirements
- [Custom blocks](./custom-blocks) — host-side query caveat for integrations
- [Installation](../getting-started/installation) — browser support tiers for default vs opt-out mode

---

# Styling

Source: https://docs.templatical.com/guide/styling

Every block in Templatical carries a `styles` property for layout and appearance, plus `visibility` for per-viewport control.

## BlockStyles

The `BlockStyles` interface controls padding and background.

```ts
interface BlockStyles {
  padding: SpacingValue;
  backgroundColor?: string;
}
```

```ts
import { createParagraphBlock } from '@templatical/types';

const block = createParagraphBlock({
  content: '<p>Styled text</p>',
});

block.styles = {
  padding: { top: 16, right: 24, bottom: 16, left: 24 },
  backgroundColor: '#f0f9ff',
};
```

## SpacingValue

Padding uses the `SpacingValue` type with four directional values in pixels.

```ts
interface SpacingValue {
  top: number;
  right: number;
  bottom: number;
  left: number;
}
```

All four properties are required. Use `0` for sides that need no spacing:

```ts
block.styles = {
  padding: { top: 0, right: 16, bottom: 0, left: 16 },
};
```

## Paragraph spacing

A paragraph block holding more than one `<p>` spaces them by `paragraphSpacing`, in px. Omit it for the built-in `8`; `0` butts the paragraphs together.

```ts
const block = createParagraphBlock({
  content: '<p>First.</p><p>Second.</p>',
  paragraphSpacing: 16,
});
```

The canvas and the exported email apply this identically, so the gap you see in the editor is the gap that ships. It only affects space *between* paragraphs inside one block — the space around the block is `styles.padding`, and a block with a single paragraph has no internal gap at all.

List spacing is not configurable: MJML resets nothing for `ul`/`ol`/`li`, so those values exist to pin down mail-client defaults rather than to be designed with.

## Block visibility

The `visibility` property on any block controls whether it renders at each breakpoint.

```ts
interface BlockVisibility {
  desktop: boolean;
  mobile: boolean;
}
```

Both default to `true`. Set a breakpoint to `false` to hide the block at that size:

```ts
// Show only on desktop
block.visibility = {
  desktop: true,
  mobile: false,
};
```

This is useful for showing different content at different screen sizes -- for example, a detailed table on desktop and a simplified list on mobile.

## Template-level settings

Beyond individual block styles, the template itself has global settings that affect the overall layout.

| Setting | Type | Description |
|---------|------|-------------|
| `width` | `number` | Template content width in px (typically 600) |
| `backgroundColor` | `string` | Outer background color behind the template |
| `textColor` | `string` | Default text color, inherited by every text block (Title, Paragraph, Menu, Table) unless the block sets its own color or an inline text-color mark. Defaults to `#1a1a1a`. |
| `linkColor` | `string` | Document-level link color applied to every link (rich-text and menu). Optional — when unset, links inherit the surrounding text color. A per-block or per-item color still overrides. |
| `linkUnderline` | `boolean` | Whether body (rich-text) links are underlined. Defaults to `true`. Buttons and menu items keep their own text-decoration. |
| `fontFamily` | `string` | Default font family for all blocks |
| `locale` | `string` | BCP-47 content language (`<html lang>`). Defaults to `en` on a blank template |
| `direction` | `'ltr' \| 'rtl'` | Writing direction. Unset follows the content language |
| `preheaderText` | `string` | Inbox preview line |

These are configured through the editor's `init()` config or by modifying the template JSON directly:

```ts
import { init } from '@templatical/editor';

const editor = await init({
  container: '#editor',
  content: {
    settings: {
      width: 640,
      backgroundColor: '#f8fafc',
      fontFamily: 'Inter, system-ui, sans-serif',
    },
    blocks: [],
  },
});
```

Individual blocks can override the template-level `fontFamily` by setting their own `fontFamily` property. When a block does not specify a font family, it inherits from the template settings.

Text color works the same way: text blocks (Title, Paragraph, Menu, Table) inherit `textColor` from the template settings, and any block that sets its own color overrides it. Customize the document default through `init({ templateDefaults: { textColor } })`.

Links follow the same cascade: set a document `linkColor` to apply one color to every link (rich-text and menu), or leave it unset so links inherit the surrounding text color. `linkUnderline` (on by default) underlines body links — buttons and menu items keep their own text-decoration. A per-block or per-item color (e.g. a Menu item's color) still overrides the document link color. Individual rich-text links can also be given their own color in the link dialog, which overrides the document link color — and because the color sits on the link itself, its underline matches.

---

# Theming

Source: https://docs.templatical.com/guide/theming

Templatical ships with a polished default theme. Two ways to override any color, radius, shadow, or font:

1. **CSS variables on the container** (`--tpl-user-*`) — the recommended approach. Works in both shadow DOM (default) and light DOM modes. Pure CSS — no JS round-trip.
2. **`ThemeOverrides` config** (JSON) — pass colors at `init()` time. Useful when colors come from runtime data (user preference, multi-tenant brand config).

Both approaches can be combined — the `ThemeOverrides` config takes precedence because it applies as inline styles, which beat class-bound variables.

## CSS variables on the container

Set `--tpl-user-*` variables on the container you pass to `init()` (or any ancestor). They inherit across the [shadow boundary](./shadow-dom) into the editor's root and override the built-in defaults:

```html
<div
  id="editor"
  style="
    --tpl-user-primary: oklch(65% 0.2 280);
    --tpl-user-primary-hover: oklch(58% 0.2 280);
    --tpl-user-primary-light: oklch(94% 0.05 280);
    --tpl-user-radius: 14px;
  "
></div>
```

Or in a stylesheet:

```css
#editor {
  --tpl-user-primary: oklch(65% 0.2 280);
  --tpl-user-primary-hover: oklch(58% 0.2 280);
  --tpl-user-primary-light: oklch(94% 0.05 280);
  --tpl-user-radius: 14px;
}
```

CSS-custom-property inheritance crosses shadow roots, so the variables you set in the host page reach the editor regardless of mount mode. You don't need separate code paths for `shadowDom: true` vs `shadowDom: false`.

### Light-mode tokens

Every token follows the same shape: declare `--tpl-user-<name>` on the container to override the SDK default.

| Override variable            | Purpose                               |
| ---------------------------- | ------------------------------------- |
| `--tpl-user-bg`              | Main background                       |
| `--tpl-user-bg-elevated`     | Elevated surfaces (panels, dropdowns) |
| `--tpl-user-bg-hover`        | Hover state background                |
| `--tpl-user-bg-active`       | Active/pressed state background       |
| `--tpl-user-border`          | Default border color                  |
| `--tpl-user-border-light`    | Subtle border (dividers, separators)  |
| `--tpl-user-text`            | Primary text                          |
| `--tpl-user-text-muted`      | Secondary text                        |
| `--tpl-user-text-dim`        | Disabled or hint text                 |
| `--tpl-user-primary`         | Primary brand color (buttons, links)  |
| `--tpl-user-primary-hover`   | Primary hover state                   |
| `--tpl-user-primary-light`   | Primary tinted background             |
| `--tpl-user-secondary`       | Secondary accent color                |
| `--tpl-user-secondary-hover` | Secondary hover state                 |
| `--tpl-user-secondary-light` | Secondary tinted background           |
| `--tpl-user-success`         | Success state color                   |
| `--tpl-user-success-light`   | Success tinted background             |
| `--tpl-user-warning`         | Warning state color                   |
| `--tpl-user-warning-light`   | Warning tinted background             |
| `--tpl-user-danger`          | Danger/error state color              |
| `--tpl-user-danger-light`    | Danger tinted background              |
| `--tpl-user-canvas-bg`       | Canvas area behind the email template |
| `--tpl-user-base-size`       | Editor UI sizing unit (`16px`) — see [Editor size](#editor-size-and-the-host-root-font-size) |
| `--tpl-user-radius`          | Default border radius (`10px`)        |
| `--tpl-user-radius-sm`       | Small border radius (`7px`)           |
| `--tpl-user-radius-lg`       | Large border radius (`14px`)          |
| `--tpl-user-font-family`     | UI font stack                         |
| `--tpl-user-shadow-sm`       | Subtle shadow                         |
| `--tpl-user-shadow`          | Default shadow                        |
| `--tpl-user-shadow-md`       | Medium shadow                         |
| `--tpl-user-shadow-lg`       | Large shadow                          |
| `--tpl-user-shadow-xl`       | Extra-large shadow                    |
| `--tpl-user-overlay`         | Modal backdrop overlay                |
| `--tpl-user-ring`            | Focus ring                            |
| `--tpl-user-transition`      | Spring easing transition              |

### Dark-mode tokens

Dark mode uses a parallel `--tpl-user-dark-*` namespace, so you can theme light and dark independently:

```css
#editor {
  /* Light overrides */
  --tpl-user-primary: oklch(65% 0.2 280);

  /* Dark overrides */
  --tpl-user-dark-primary: oklch(75% 0.16 280);
  --tpl-user-dark-bg: oklch(18% 0.005 280);
  --tpl-user-dark-text: oklch(94% 0.005 280);
}
```

Replace `--tpl-user-` with `--tpl-user-dark-` in any token name from the table above to target dark mode. The editor activates dark mode via `data-tpl-theme="dark"` on its root and reads the dark-namespace defaults; your `--tpl-user-dark-*` overrides plug in there.

Dark mode is opt-in via the `uiTheme` config — set `'dark'` or `'auto'` to enable. See [Dark mode](#dark-mode) below.

## Editor size and the host root font-size

The editor's UI sizing — spacing, font sizes, border radii — is anchored to a single base unit, `--tpl-user-base-size`, which defaults to **`16px`**.

Because the default is a fixed pixel value (not `rem`), **the editor renders at a consistent size regardless of your page's root font-size.** If your design system sets a custom `html { font-size }` — for example `8px` to make `1rem = 8px` — the editor is unaffected and stays at its intended size. (A `rem` always resolves against the document root, even inside the editor's shadow root, so without this anchor a custom root font-size would scale the whole editor up or down.)

### Scaling the editor up or down

Set `--tpl-user-base-size` on the container (or any ancestor) to scale the entire editor UI proportionally:

```css
#editor {
  --tpl-user-base-size: 18px; /* ~12% larger chrome, text, spacing, radii */
}
```

```css
#editor {
  --tpl-user-base-size: 14px; /* more compact */
}
```

Any CSS length works. If you prefer the editor to track your custom root font-size after all, set a `rem` value — e.g. `--tpl-user-base-size: 2rem` against an `8px` root resolves to `16px`.

This affects the **editor chrome** only. The email content on the canvas uses the pixel sizes stored on each block, so your template renders identically no matter how the editor UI is scaled.

The same `--tpl-user-base-size` knob applies to the standalone `@templatical/media-library` SDK. Like every `--tpl-user-*` variable, it works identically in shadow-DOM and light-DOM modes.

## ThemeOverrides config

Use the `theme` field of `init()` when you need to apply theme overrides programmatically (multi-tenant branding, user preference toggles, etc.):

```ts
import { init } from "@templatical/editor";

const editor = await init({
  container: "#editor",
  theme: {
    primary: "#6d28d9",
    primaryHover: "#5b21b6",
    primaryLight: "#ede9fe",
    bg: "#fafafa",
    text: "#1a1a1a",
  },
});
```

`ThemeOverrides` is applied as inline styles on the editor's `.tpl` root, so it wins over the class-bound defaults and over any `--tpl-user-*` variables you've set on the container.

### Available config keys

The JSON keys mirror the CSS variable names (camelCase instead of kebab-case). All keys are optional — unset keys fall back to `--tpl-user-*` or the built-in default.

| Token            | Purpose                               |
| ---------------- | ------------------------------------- |
| `bg`             | Main background                       |
| `bgElevated`     | Elevated surfaces                     |
| `bgHover`        | Hover state background                |
| `bgActive`       | Active/pressed state background       |
| `border`         | Default border color                  |
| `borderLight`    | Subtle border                         |
| `text`           | Primary text                          |
| `textMuted`      | Secondary text                        |
| `textDim`        | Disabled or hint text                 |
| `primary`        | Primary brand color                   |
| `primaryHover`   | Primary hover state                   |
| `primaryLight`   | Primary tinted background             |
| `secondary`      | Secondary accent color                |
| `secondaryHover` | Secondary hover state                 |
| `secondaryLight` | Secondary tinted background           |
| `success`        | Success state color                   |
| `successLight`   | Success tinted background             |
| `warning`        | Warning state color                   |
| `warningLight`   | Warning tinted background             |
| `danger`         | Danger/error state color              |
| `dangerLight`    | Danger tinted background              |
| `canvasBg`       | Canvas area behind the email template |

### TypeScript type

```ts
import type { ThemeOverrides } from "@templatical/types";
```

## Dark mode

The editor supports a dark theme for its UI chrome (header, sidebars, toolbar, modals). Dark mode is independent of the canvas dark-preview toggle, which simulates how emails look in recipients' dark-themed email clients.

### Activation

Set `uiTheme` in the init config. The default is `'auto'`, which follows the user's system preference via `prefers-color-scheme`.

```ts
const editor = await init({
  container: "#editor",
  uiTheme: "dark", // 'light' | 'dark' | 'auto'
});
```

### Runtime toggle

Switch the theme without re-initializing:

```ts
editor.setTheme("dark");
editor.setTheme("light");
editor.setTheme("auto"); // follow system preference
```

### Dark overrides via `ThemeOverrides`

Customize the dark palette separately using the `dark` key inside `theme`:

```ts
const editor = await init({
  container: "#editor",
  uiTheme: "auto",
  theme: {
    // Light mode overrides
    primary: "#6d28d9",
    primaryHover: "#5b21b6",
    // Dark mode overrides
    dark: {
      primary: "#a78bfa",
      primaryHover: "#c4b5fd",
      bg: "#1e1e2e",
      bgElevated: "#2a2a3c",
    },
  },
});
```

**Priority chain.** When dark mode is active, only `theme.dark` overrides are applied as inline styles. Unset dark tokens fall back through `--tpl-user-dark-*` (if set on the container) to the built-in dark defaults.

| Scenario                                    | What applies                                                                     |
| ------------------------------------------- | -------------------------------------------------------------------------------- |
| Light mode, no overrides                    | Built-in light defaults                                                          |
| Light mode, `--tpl-user-*` on container     | Container overrides + remaining light defaults                                   |
| Light mode, `theme` config                  | `theme` overrides + container `--tpl-user-*` + remaining light defaults          |
| Dark mode, no overrides                     | Built-in dark defaults                                                           |
| Dark mode, `--tpl-user-dark-*` on container | Container overrides + remaining dark defaults                                    |
| Dark mode, `theme.dark` config              | `theme.dark` overrides + container `--tpl-user-dark-*` + remaining dark defaults |

### TypeScript types

```ts
import type { ThemeOverrides, UiTheme } from "@templatical/types";

// UiTheme = 'light' | 'dark' | 'auto'
// ThemeOverrides includes a `dark?: Omit<ThemeOverrides, 'dark'>` key
```

## Override surfaces

CSS variables on the container are simpler when:

- Theme colors are static or known at build time.
- You want the same theme tokens to apply to both the editor and your surrounding UI (set them on a shared wrapper).
- You're using CSS Modules, Tailwind, or another style system that already deals in CSS variables.

`ThemeOverrides` JSON is simpler when:

- Theme colors come from runtime data (user account preferences, multi-tenant branding).
- You're working in a framework that doesn't naturally expose a single styled container element.
- You want a single typed API surface and don't want to spell variable names by hand.

Both surfaces compose: container variables provide the default, `ThemeOverrides` patches specific tokens on top. The editor's `theme` config option always wins because it applies as inline styles.

## Matching your own UI to the editor's theme

If you want your surrounding chrome (e.g. a wrapper toolbar or status bar) to inherit the editor's palette, set the override variables on a wrapper that contains both your UI and the editor, then reference them from both:

```html
<div
  class="my-app"
  style="--tpl-user-primary: #6d28d9; --tpl-user-bg: #fafafa;"
>
  <header class="my-app__header">…</header>
  <div id="editor"></div>
</div>
```

```css
.my-app__header {
  background: var(--tpl-user-bg);
  color: var(--tpl-user-primary);
}
```

In shadow-DOM mode (the default), the editor's internal `--tpl-*` variables stay inside the shadow root and aren't visible to host CSS. Your host CSS reads from the `--tpl-user-*` variables you set yourself, so the two stay in sync.

See the [Shadow DOM guide](./shadow-dom) for the mechanics of how variables cross the boundary.

---

# Templatical

Source: https://docs.templatical.com/



---

# License FAQ

Source: https://docs.templatical.com/license-faq

This page answers the questions teams ask most often when evaluating Templatical's license. It's intentionally plain-English; if you need formal terms, the authoritative documents are [`LICENSE`](https://github.com/templatical/sdk/blob/main/LICENSE) (FSL-1.1-MIT) and [`LICENSE-MIT`](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) (MIT).

## TL;DR

- **You can use Templatical for free in any commercial product**, including paid SaaS, internal tools, and on-premise installs.
- **The only thing you can't do** is repackage Templatical itself as a competing email-editor product or service.
- **After two years**, every release of FSL-licensed code automatically becomes MIT. No action required.
- **Only the editor, core and media library are FSL-licensed** — every other package is pure MIT today.

## What is FSL-1.1-MIT?

FSL stands for [**Functional Source License**](https://fsl.software/). It's a modern source-available license designed by Sentry to balance two things teams care about:

- **Freedom** — the code is open, you can read it, fork it, modify it, and redistribute it.
- **Sustainability** — the maintainer can build a viable business around it without an Amazon-sized cloud provider repackaging the project as a managed service.

FSL-1.1-**MIT** is the variant that automatically converts to the MIT License after two years. So Templatical's source is public today with a single narrow restriction, and becomes fully MIT-licensed open source two years after each release.

**Templatical uses the canonical FSL-1.1-MIT text, unmodified**, plus one clearly-marked **Additional Permission** that grants embedding explicitly. The Additional Permission only adds rights — it cannot reduce anything the base license grants. If your legal team compares [`LICENSE`](https://github.com/templatical/sdk/blob/main/LICENSE) against the [upstream template](https://github.com/getsentry/fsl.software/blob/main/FSL-1.1-MIT.template.md), the base text matches byte for byte, and the only delta is the section below it.

## Which packages use which license?

| Package | License |
|---|---|
| `@templatical/editor` | [FSL-1.1-MIT](https://github.com/templatical/sdk/blob/main/LICENSE) (becomes MIT after 2 years) |
| `@templatical/core` | [FSL-1.1-MIT](https://github.com/templatical/sdk/blob/main/LICENSE) (becomes MIT after 2 years) |
| `@templatical/media-library` | [FSL-1.1-MIT](https://github.com/templatical/sdk/blob/main/LICENSE) (becomes MIT after 2 years) |
| `@templatical/types` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/renderer` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/quality` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-beefree` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-unlayer` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-html` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-mjml` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-topol` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-stripo` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-chamaileon` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |
| `@templatical/import-easy-email-pro` | [MIT](https://github.com/templatical/sdk/blob/main/LICENSE-MIT) |

The split exists so anything you'd build into your own backend or codegen pipeline (types, renderer, linter, importers) is permissive MIT and free of any future-license consideration.

## Can I use Templatical commercially?

**Yes.** You can embed Templatical in any commercial product — paid SaaS, internal tools, on-premise software, agency builds, anything — without paying us and without asking permission.

The only restriction is the one in the LICENSE: you can't take Templatical, slap a different name on it, and offer *that* as a competing email-editor product — hosted, self-installed, or otherwise.

## Can I embed Templatical in my SaaS?

**Yes.** This is the most common use case and it's fully permitted.

Concretely allowed:

- ✅ Embedding the editor in your CRM, marketing automation, transactional email tool, newsletter platform, or any product where email composition is one feature among many.
- ✅ Charging your customers for using your product, including the embedded editor as part of it.
- ✅ Customizing the editor with your brand, theme, custom blocks, and Cloud features.
- ✅ Using it inside an internal tool that never ships to external customers.

Concretely not allowed:

- ❌ Building a product whose primary purpose is composing or designing email templates — "Templatical, but yours" — whether you host it or ship it self-installed.
- ❌ Forking Templatical and offering the fork as a drop-in commercial replacement for Templatical itself.

If you're not sure whether your use case crosses the line, [open a discussion](https://github.com/templatical/sdk/discussions) or email <hi@templatical.com>. We'd rather give you a clear answer up front than have ambiguity.

## What does "competing use" mean concretely?

A "competing use" is one where the email editor *is* the product. Some worked examples:

| Use case | Allowed? |
|---|---|
| You add Templatical to your CRM so users can compose campaign emails | ✅ Yes — email editor is a feature of your CRM. |
| You add Templatical to a transactional email API so customers design templates before sending via your API | ✅ Yes — email editor is a feature of your sending platform. |
| You build a newsletter SaaS where Templatical is one of several composition tools | ✅ Yes — email editor is a feature of your newsletter product. |
| You embed the editor inside your company's internal marketing portal | ✅ Yes — internal use is unrestricted. |
| You fork Templatical, rebrand it, and sell hosted Templatical-clone subscriptions | ❌ No — that substitutes for Templatical itself, hosted or not. |
| You build "Templatical Cloud, but cheaper" and offer it as a SaaS | ❌ No — that substitutes for a product we offer using the editor. |

The rule of thumb: if you remove Templatical from your product, do you still have a product? If yes, you're fine.

## When does FSL code become MIT?

**Two years after each release.** Every FSL-licensed release of `@templatical/editor`, `@templatical/core`, and `@templatical/media-library` automatically converts to the MIT License two years from the date that specific version was published.

That means if a stable version of Templatical 1.0 is released in 2026, that exact version becomes MIT-licensed in 2028 — automatically, with no action required from anyone. Newer releases inherit a fresh two-year clock.

In practice, this means you always have a fully MIT-licensed version of the codebase available, just one that's two years behind the latest release.

## What if the license changes later?

Any version already published stays under the terms it shipped with. A license change applies to releases made after it — it cannot reach backwards and withdraw rights from a version you already have.

The two-year MIT conversion works the same way: [`LICENSE`](https://github.com/templatical/sdk/blob/main/LICENSE) grants it **irrevocably** and in advance, for each release at the moment that release is published. Nothing we do later can take it back.

## Why not pure MIT from the start?

Templatical is built and maintained primarily by one developer. Pure MIT means a well-funded competitor could take Templatical, host it as a managed service tomorrow, and undercut the project's ability to fund its own development — without contributing anything back.

FSL-1.1-MIT keeps the code open and forkable for everyone with legitimate use cases (which is essentially everyone reading this), while preventing exactly the parasitic-managed-service scenario. After two years, that restriction lifts automatically and the code is fully MIT.

The same approach is used by [Sentry](https://sentry.io), [PowerSync](https://www.powersync.com), [GitButler](https://gitbutler.com), and others who want a sustainable path to open source without going proprietary.

## Can I contribute to Templatical?

**Yes, please.** Community contributions are welcome — bug fixes, features, docs improvements, additional locales, and custom block examples are all appreciated.

By contributing, you agree your contribution is licensed under the same license as the package you're contributing to (MIT for `types`, `renderer`, `quality`, `import-beefree`, `import-unlayer`, `import-html`, `import-mjml`, `import-topol`, `import-stripo`, `import-chamaileon`, `import-easy-email-pro`; FSL-1.1-MIT for `editor`, `core`, `media-library`).

There is currently **no separate Contributor License Agreement (CLA)** to sign — your PR alone is enough.

See [`CONTRIBUTING.md`](https://github.com/templatical/sdk/blob/main/CONTRIBUTING.md) for the full contribution guide.

## Do I need to display attribution?

**Yes, for redistributed code.** If you redistribute Templatical's source or build artifacts, you must keep the license notice. This is the same as any standard open-source license.

You do **not** need to display "Powered by Templatical" or any user-visible credit in the email editor itself. The license does not require attribution in the editor UI.

By default, the editor renders a small "Powered by Templatical" footer in the canvas. You can hide it by passing `branding: false` to `init()`:

```ts
await init({
  container: "#editor",
  branding: false, // hides the footer
});
```

There is no header logo or other forced branding — the footer is the only attribution surface, and it's opt-out.

## Is using the Cloud tier required?

**No.** The Cloud tier is optional. The OSS SDK works completely standalone — every feature in `@templatical/editor` (the OSS init path) runs without any backend.

Cloud is optional hosted infrastructure (AI rewrite, real-time collaboration, managed storage). Comments, saved blocks, version history, test email, and media are available on `init()` via providers you implement — Cloud is one adapter, not the feature.

## What if I have a question this doesn't answer?

For licensing questions, email <hi@templatical.com>. For everything else, [open a discussion](https://github.com/templatical/sdk/discussions) or [file an issue](https://github.com/templatical/sdk/issues).

If you spot something unclear or missing in this FAQ, please tell us — clarity benefits everyone.

---

# Accessibility linter

Source: https://docs.templatical.com/quality/accessibility/

`lintAccessibility(content, options?)` is the accessibility checker inside [`@templatical/quality`](../). It operates on the JSON `TemplateContent` block tree, runs in the browser or in Node.js, and ships with no Vue or DOM dependencies — so the same package validates templates inside the editor and as a CI gate on stored fixtures.

## Purpose

Email accessibility is under-tooled. Most builders either bury accessibility behind a paywall, run shallow content-tone checks, or skip it entirely. We catch the authoring mistakes that recur every day:

- Missing or filename-style alt text
- Low-contrast text and buttons
- Vague link / CTA copy ("click here", "read more")
- Heading-level skips that break the document outline
- Tiny body text, oversized all-caps blocks, undersized touch targets
- `target="_blank"` links missing `rel="noopener"`
- Decorative images with leftover alt text

Catch problems while you're authoring, not after recipients see broken alt text, unreadable contrast, or vague CTAs. Every rule fires on a clear, named condition, so the output is predictable and stays predictable as templates evolve. The same checks align with the EU Accessibility Act (enforceable June 2025).

## API

```ts
import { lintAccessibility } from "@templatical/quality";

const issues = lintAccessibility(content); // options is an optional second argument
// issues: LintIssue[] — each entry has ruleId starting with "a11y."
```

The function takes a `TemplateContent` and an optional [`LintOptions`](../options) object. It returns a flat array of `LintIssue` objects with `ruleId`, `severity`, `message`, `blockId`, and optionally a `fix` patch.

Accessibility-specific config lives under `LintOptions.accessibility`. Set `accessibility: false` to disable the linter entirely.

```ts
lintAccessibility(content, {
  accessibility: {
    rules: { "a11y.img-missing-alt": "warning" },
    thresholds: { minFontSize: 16 },
  },
});
```

In the editor, the `useTemplateLint` composable lazy-imports `@templatical/quality`, debounces re-lint on content changes, and wires `applyFix(issue)` through the editor's block-update path so fixes land as proper undo entries. Accessibility issues appear in the **Issues** sidebar tab alongside structure issues.

## Quick links

- [Rule catalog](./rule-catalog) — every accessibility rule with severity, rationale, and examples.
- [Options](../options) — shared across both linters.
- [Severity & fixes](../severity-and-fixes) — how the severity model works and how auto-fix patches are applied.
- [Headless usage](../headless-usage) — validating stored templates in CI.
- [Contributing locales](../contributing-locales) — adding rule messages + vague-text dictionaries.

---

# Accessibility rule catalog

Source: https://docs.templatical.com/quality/accessibility/rule-catalog

The 21 rules `lintAccessibility` ships, grouped by what they check. Each rule lives in `packages/quality/src/accessibility/rules/`; severity, message templates, and dictionaries are user-overridable per [Options](../options).

## Images

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `a11y.img-missing-alt` | error | — | Missing alt text — Screen readers announce undefined or empty alt as the image filename or skip the image entirely. Email clients also block images by default; alt text is what 30–50% of recipients see first. [1](https://www.w3.org/WAI/tutorials/images/) |
| `a11y.img-alt-is-filename` | warning | — | Alt text looks like a filename — Filenames like 'IMG_1234.jpg' or 'Screen Shot 2026.png' carry no useful meaning. Replace with a short description of what the image conveys. |
| `a11y.img-alt-too-long` | warning | — | Alt text is too long — Screen readers don't pause inside alt text. Long alt strings become a wall of speech. Aim for under ~125 characters; put extra context in surrounding copy. |
| `a11y.img-decorative-needs-empty-alt` | info | yes | Decorative image has alt text — Decorative images should be skipped by screen readers. Setting alt='' (empty) signals that intent. Non-empty alt on a decorative image is a contradiction. |
| `a11y.img-linked-no-context` | warning | — | Linked image has no destination context — When an image is also a link, alt text doubles as the link label. Describing only what the image shows leaves users guessing where the link goes. |

## Headings

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `a11y.heading-empty` | error | — | Heading has no text — Empty headings produce silent landmarks for screen-reader users navigating by heading. Either add text or remove the block. |
| `a11y.heading-skip-level` | error | — | Heading level skipped — Skipping heading levels (e.g. H1 → H3) breaks the document outline that assistive tech relies on for navigation. Step one level at a time. |
| `a11y.heading-multiple-h1` | warning | — | Multiple H1 headings — An email should have a single H1 that names the message. Multiple H1s confuse the document outline and weaken landmark navigation. |

## Links

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `a11y.link-empty` | error | — | Link has no accessible text — A link with no visible text and no nested image with alt is invisible to screen readers and unclickable for many users. |
| `a11y.link-vague-text` | warning | — | Vague link text — Phrases like 'click here' or 'read more' tell screen-reader users nothing when listed out of context. Use descriptive link text that names the destination. Outer punctuation and decorative symbols are stripped before matching, so `Click here!`, `→ click here`, and `»click here«` all flag. |
| `a11y.link-href-empty` | error | — | Link has empty href — An anchor with no destination (empty href or '#') is broken — recipients click and nothing happens, or the page jumps to top. |
| `a11y.link-target-blank-no-rel` | warning | yes | target="_blank" missing rel="noopener" — Links opening in a new tab without rel='noopener' or rel='noreferrer' allow the destination to read window.opener and tamper with the originating page. A small but real security/privacy footgun. |
| `a11y.link-nested-anchor` | error | — | Anchor nested inside another anchor — Invalid HTML; email clients render nested `<a>` inconsistently (some flatten, some strip the inner, some break click targets). Flatten to a single anchor. |

## Text

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `a11y.text-all-caps` | warning | — | All-caps body text — Long stretches of all-caps text are read letter-by-letter by some screen readers and slow visual reading by 10–20%. Use sentence case for body copy; reserve caps for short labels. |
| `a11y.text-justified` | warning | yes | Justified body text — Stretching word spacing to flush both margins opens uneven "rivers" of white space that are hardest to track for dyslexic and low-vision readers, and email clients justify without hyphenation so the gaps get wider still. Only paragraph blocks are checked; every other block stores alignment as a field whose type excludes `justify`. The fix removes the declaration rather than forcing `left`, so the paragraph inherits the document default — which is also correct for right-to-left content. [1](https://www.w3.org/WAI/WCAG21/Understanding/visual-presentation) |
| `a11y.text-low-contrast` | error | — | Heading contrast is too low — WCAG AA requires 4.5:1 for body text and 3:1 for large text (18pt / ~24px). Headings ≥24px (H1, H2) get the relaxed 3:1 threshold; H3 (22px) and H4 (18px) require 4.5:1. The bold-text relaxation isn't applied — TipTap stores bold inline in HTML, not as a structured field. Below that, text becomes unreadable for low-vision users and in bright outdoor light. Only Title blocks are checked; paragraph color lives in inline HTML. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) |
| `a11y.text-too-small` | warning | — | Text is too small — Email body text below 14px becomes hard to read on mobile. Some clients also auto-zoom or scale up small fonts in unpredictable ways. Stay at 14px or larger. |

## Buttons

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `a11y.button-vague-label` | warning | — | Vague button label — A button labeled 'Click here' or 'Submit' tells the user nothing about what will happen. Use action-oriented labels that name the outcome ('Buy ticket', 'Reset password'). Same outer-punctuation handling as `a11y.link-vague-text` — `Submit!`, `→ OK`, and `»click«` all flag. |
| `a11y.button-touch-target` | warning | — | Button touch target is too small — WCAG 2.5.5 (AAA) and Apple/Google UX guidelines recommend touch targets of at least 44×44px. Smaller buttons cause mis-taps on mobile. |
| `a11y.button-low-contrast` | error | — | Button text contrast is too low — Same WCAG AA thresholds as `a11y.text-low-contrast`: 4.5:1 normally, 3:1 for buttons with `fontSize >= 24` (WCAG large text). Default-sized buttons (15px) require the strict ratio. Buttons that fail this become unreadable for users with low vision and in bright outdoor light. [1](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) |

## Template-level

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `a11y.missing-preheader` | info | — | Missing preheader text — The preheader is the preview snippet shown beside the subject line in most inboxes. Without it, recipients see a fragment of the first heading or a stray alt tag — a missed chance to set context. |

---

# Contributing locales

Source: https://docs.templatical.com/quality/contributing-locales

`@templatical/quality` ships locale-aware data sets keyed by language:

1. **Accessibility rule messages** (`src/accessibility/messages/{locale}.ts`) — strings the editor shows for each `a11y.*` issue.
2. **Vague-text dictionaries** (`src/accessibility/dictionaries/{locale}.ts`) — phrase lists used by `a11y.link-vague-text`, `a11y.button-vague-label`, and `a11y.img-linked-no-context`.
3. **Structure rule messages** (`src/structure/messages/{locale}.ts`) — strings for each `structure.*` issue.
4. **Link rule messages** (`src/links/messages/{locale}.ts`) — strings for each `link.*` issue.

Each set mirrors the editor's locale set. The structure and link linters have no equivalent of vague-text dictionaries — their rules are deterministic and locale-agnostic, only the message text needs translating.

## File layout

```
packages/quality/src/accessibility/messages/
  en.ts       ← source of truth (typed implicitly)
  de.ts       ← annotated `typeof en`
  index.ts    ← exports formatMessage(), getMessages()

packages/quality/src/accessibility/dictionaries/
  en.ts
  de.ts
  index.ts    ← exports getDictionary(), normalizeForMatch()

packages/quality/src/structure/messages/
  en.ts       ← source of truth
  de.ts       ← annotated `typeof en`
  index.ts    ← exports formatStructureMessage(), getStructureMessages()

packages/quality/src/links/messages/
  en.ts       ← source of truth
  de.ts       ← annotated `typeof en`
  index.ts    ← exports formatLinkMessage(), getLinkMessages()
```

## Adding a locale

You need **three** files (or two if you're skipping the vague-text dictionary): a message map per linter and a dictionary. Drop the files and they're picked up automatically — every locale registry is built at compile time via `import.meta.glob`, so there's no map to update.

Follow the `typeof en` pattern for every file. The annotation is the contract: any missing key, extra key, or wrong type fails `pnpm run typecheck`. Runtime parity tests verify `{name}` placeholder positions match across locales.

### 1. Accessibility rule messages

Drop `accessibility/messages/<lang>.ts` and translate every value, preserving `{name}` placeholders exactly:

```ts
// accessibility/messages/pt.ts
import type en from "./en";

const pt: typeof en = {
  "a11y.img-missing-alt":
    "Imagem sem texto alternativo. Adicione uma descrição curta ou marque a imagem como decorativa.",
  "a11y.img-alt-too-long":
    "Texto alternativo tem {length} caracteres; mantenha abaixo de {max}.",
  // …one key per accessibility rule
};

export default pt;
```

### 2. Vague-text dictionary

Drop `accessibility/dictionaries/<lang>.ts`:

```ts
// accessibility/dictionaries/pt.ts
import type en from "./en";

const pt: typeof en = {
  vagueLinkText: ["clique aqui", "aqui", "leia mais", "saiba mais"],
  vagueButtonLabels: ["clique aqui", "clique", "enviar"],
  linkedImageActionHints: ["compre", "leia", "veja", "baixe", "descubra"],
};

export default pt;
```

### 3. Structure rule messages

Drop `structure/messages/<lang>.ts`:

```ts
// structure/messages/pt.ts
import type en from "./en";

const pt: typeof en = {
  "structure.duplicate-block-id":
    "ID de bloco aparece {count} vezes na árvore. Cada bloco precisa ter um ID único.",
  "structure.section-column-mismatch":
    'Seção usa layout "{layout}" (espera {expected} colunas) mas tem {actual}. Estado corrompido.',
  // …one key per structure rule
};

export default pt;
```

### 4. Link rule messages

Drop `links/messages/<lang>.ts`:

```ts
// links/messages/pt.ts
import type en from "./en";

const pt: typeof en = {
  "link.javascript-protocol":
    'URL usa o protocolo "javascript:", que é removido na renderização por segurança. Substitua por um link real ou remova a URL.',
  "link.unsupported-protocol":
    'URL usa o protocolo "{protocol}", que a maioria dos clientes de e-mail não suporta. Use http, https, mailto, tel ou sms.',
  // …one key per link rule
};

export default pt;
```

That's it — `SUPPORTED_MESSAGE_LOCALES`, `SUPPORTED_DICTIONARY_LOCALES`, `SUPPORTED_STRUCTURE_MESSAGE_LOCALES`, and `SUPPORTED_LINK_MESSAGE_LOCALES` reflect the new locale automatically. No registry edit, no test update.

## Phrase guidelines (vague-text dictionary)

- **Match, not regex.** The vague-text rules normalize the anchor / button text — lowercase, collapse whitespace, strip leading/trailing non-alphanumeric characters (punctuation, arrows, decorative quotes) — then test `phrases.includes(text)`. So `"Click here!"`, `"→ click here"`, and `"»click here«"` all collapse to `click here` and match the same dictionary entry. Don't add punctuation variants — they're redundant. Each entry is still an exact phrase match; don't try to encode regex patterns.
- **Lowercase only.** Comparison is case-insensitive on the input side.
- **Common, not exhaustive.** The point is to catch the most frequent vague phrases native authors fall into. A 50-entry list does more harm than good (false positives).
- **Don't translate English phrases.** The dictionary is a cross-locale union — every registered locale's phrases match regardless of the active `locale` option. So your `pt.ts` only needs Portuguese phrases; English `click here` is already covered by the union.
- **No region duplicates.** `de-AT` resolves to the same union; one entry per language.
- **`linkedImageActionHints` is per-token, not per-phrase.** `a11y.img-linked-no-context` tokenizes the alt text on non-letter/digit boundaries and checks each token against the hint list. Add **single action verbs** in the form authors actually write them ("buy", "kaufen", "compre"), not multi-word phrases — a phrase like `"jetzt kaufen"` will never match because tokens are checked individually.

## Match order

- **Vague-text dictionary** — `getDictionary(locale)` returns a union of every registered locale's phrases (and action hints). The `locale` argument is accepted for API symmetry but currently doesn't change the returned set; a vague phrase is universally vague, and an action verb in any registered language counts as link-destination context, so detection is cross-locale by design.
- **Rule messages** — `formatMessage(locale, ruleId, params?)` (accessibility), `formatStructureMessage(locale, ruleId, params?)` (structure), and `formatLinkMessage(locale, ruleId, params?)` (links) resolve the localized template via the matching `messages/{locale}.ts` file and interpolate `{name}` placeholders. All three fall back to English when the locale isn't bundled.

---

# Headless usage

Source: https://docs.templatical.com/quality/headless-usage

`@templatical/quality` is JSON-only and has no DOM dependency, so the same linters run in any Node.js context: CI, build pipelines, server-side validation, batch jobs.

`lintTemplate(content, options?)` runs every linter — accessibility, structure, and links — and returns the merged `LintIssue[]`. It's the recommended entry point: one call, and any future linter category is included automatically.

```ts
import { lintTemplate } from "@templatical/quality";

const issues = lintTemplate(content); // a11y, then structure, then link issues
```

The per-linter functions — `lintAccessibility(content, options?)`, `lintStructure(content, options?)`, `lintLinks(content, options?)` — return the same shape and stay exported for when you want to run only a subset. You can also run a subset through `lintTemplate` by disabling categories: `lintTemplate(content, { structure: false, links: false })` runs accessibility only.

## Validate before storing

Reject template JSON that fails the linter at the point it enters your system — CMS save handler, API endpoint, ingestion job:

```ts
import { lintTemplate } from "@templatical/quality";
import type { TemplateContent } from "@templatical/types";

export function assertValid(content: TemplateContent): void {
  const errors = lintTemplate(content).filter((i) => i.severity === "error");
  if (errors.length > 0) {
    throw new Error(
      `Template fails quality checks:\n${errors
        .map((e) => `  [${e.ruleId}] ${e.message}`)
        .join("\n")}`,
    );
  }
}
```

`structure.*` errors typically indicate data corruption (duplicate IDs, layout/children mismatch) and should always block a save. `link.javascript-protocol` is also an error — a dangerous scheme that the editor's render-time sanitizer would strip silently. `a11y.*` errors are content quality and may warrant a softer policy.

## CI guard for stored templates

If your application stores `TemplateContent` JSON in a database, run the linters in CI against every stored fixture so regressions can't ship:

```ts
// scripts/lint-templates.ts
import { lintTemplate } from "@templatical/quality";
import { templates } from "../fixtures/templates";

const SEVERITY_RANK = { error: 3, warning: 2, info: 1 };

let failed = 0;

for (const [name, content] of Object.entries(templates)) {
  const issues = lintTemplate(content).filter(
    (i) => SEVERITY_RANK[i.severity] >= SEVERITY_RANK.warning,
  );

  if (issues.length === 0) {
    console.log(`OK ${name}: clean`);
    continue;
  }
  failed++;
  console.error(`FAIL ${name}: ${issues.length} issue(s)`);
  for (const issue of issues) {
    const where = issue.blockId ? `block ${issue.blockId}` : "template";
    console.error(`  [${issue.severity}] ${issue.ruleId} (${where}): ${issue.message}`);
  }
}

if (failed > 0) process.exit(1);
```

Run via `tsx scripts/lint-templates.ts` and wire it into your CI workflow. The Templatical playground does exactly this — see `apps/playground/scripts/lint-templates.ts` in the repo.

## Filtering by category

Rule IDs are namespaced (`a11y.*`, `structure.*`, `link.*`), so grouping or filtering by linter is a `startsWith` check:

```ts
const issues = lintTemplate(content);
const a11y = issues.filter((i) => i.ruleId.startsWith("a11y."));
const structural = issues.filter((i) => i.ruleId.startsWith("structure."));
const links = issues.filter((i) => i.ruleId.startsWith("link."));
```

## Custom severity policy

A team may want errors-only in CI but the full info-level output in development:

```ts
const SEVERITIES = process.env.CI
  ? ["error"]
  : ["error", "warning", "info"];

const issues = lintAccessibility(content).filter((i) =>
  SEVERITIES.includes(i.severity),
);
```

## Building your own rules

You can compose your own walkers using the same primitives the package ships:

```ts
import { walkBlocks, getContrastRatio } from "@templatical/quality";

walkBlocks(content, (block, ctx) => {
  if (block.type === "title" && block.color) {
    const ratio = getContrastRatio(block.color, ctx.resolvedBackgroundColor);
    if (ratio < 4.5) {
      console.warn(`Heading ${block.id} contrast ${ratio.toFixed(2)}:1`);
    }
  }
});
```

`walkBlocks` resolves the nearest opaque ancestor background per block, so contrast checks "just work" without re-implementing the section/column traversal.

For URL-scoped rules, use `walkUrls` instead — it yields one `UrlOccurrence` per URL field across the template (anchors, button, image-link, video, menu-item, social-icon) without re-walking the tree per rule:

```ts
import { walkUrls } from "@templatical/quality";

for (const { url, blockId, source } of walkUrls(content)) {
  if (source === "anchor" && url.startsWith("https://tracking.")) {
    console.warn(`Block ${blockId} routes through tracking domain`);
  }
}
```

If you'd like your custom rule to participate in the orchestrator alongside the built-ins (severity overrides, localized messages, the editor Issues panel), implement the `Rule` interface — `block` / `template` return a `RuleHit` (`blockId`, optional `params`, optional `fix`) and the orchestrator combines it with the rule's `meta` and the active locale's message template. The same `runRules` helper powers `lintAccessibility`, `lintStructure`, and `lintLinks`, and `lintTemplate` simply fans out to all three.

---

# Quality

Source: https://docs.templatical.com/quality/

`@templatical/quality` is the umbrella package for Templatical's template-quality tooling — deterministic, JSON-only linters that catch authoring mistakes inside the editor and in headless / CI checks. MIT-licensed, ESM, no Vue, no DOM.

## Linters

| Linter | What it catches | Default severities |
|---|---|---|
| **[Accessibility](./accessibility/)** | Missing alt text, low contrast, vague CTAs, heading-skip, undersized touch targets, ALL CAPS body, target=_blank missing rel, missing preheader, … | mostly error/warning |
| **[Structure](./structure/)** | Duplicate block IDs, sections with the wrong column count, nested sections, empty sections, empty columns | mostly error; some warning |
| **[Links](./links/)** | Dangerous URL schemes (`javascript:`), unsupported protocols, malformed `mailto:` / `tel:`, staging / localhost URLs leaking into a template | mostly warning; `link.javascript-protocol` is error |

All three linters return the same `LintIssue` shape and share the same options surface (`LintOptions`) — so consumers can run them in any combination, merge results, and filter by `ruleId` prefix (`a11y.*`, `structure.*`, `link.*`) when grouping. Each linter's per-rule severities and tool-specific knobs live under its own namespace (`accessibility`, `structure`, `links`); set any of them to `false` to disable that linter entirely.

**Run everything in one call.** `lintTemplate(content, options?)` is the single aggregate entry point — it runs all three linters and returns the merged `LintIssue[]` (accessibility, then structure, then links). Reach for it by default; the per-linter functions stay exported for when you want to run just a subset. To skip a category, set its key to `false`: `lintTemplate(content, { structure: false })`. See [Headless usage](./headless-usage).

## Architecture

<svg viewBox="0 0 640 280" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:640px;margin:1.5em auto;display:block;">
  <defs>
    <marker id="ah-quality" markerWidth="8" markerHeight="6" refX="4" refY="3" orient="auto">
      <path d="M0,0 L8,3 L0,6" fill="#94a3b8"/>
    </marker>
  </defs>
  <!-- Step 1: Input -->
  <rect x="10" y="10" width="180" height="80" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" rx="10"/>
  <text x="100" y="38" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="600" fill="#1e293b" text-anchor="middle">TemplateContent</text>
  <text x="100" y="60" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">JSON block tree</text>
  <text x="100" y="76" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">from the editor or DB</text>
  <!-- Arrow -->
  <line x1="190" y1="50" x2="225" y2="50" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ah-quality)"/>
  <!-- Step 2: Engines (three boxes stacked) -->
  <rect x="230" y="2" width="180" height="30" fill="#fef3c7" stroke="#f59e0b" stroke-width="1.5" rx="8"/>
  <text x="320" y="16" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="600" fill="#1e293b" text-anchor="middle">lintAccessibility()</text>
  <text x="320" y="28" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#92400e" text-anchor="middle">a11y.* rules</text>
  <rect x="230" y="38" width="180" height="30" fill="#fef3c7" stroke="#f59e0b" stroke-width="1.5" rx="8"/>
  <text x="320" y="52" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="600" fill="#1e293b" text-anchor="middle">lintStructure()</text>
  <text x="320" y="64" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#92400e" text-anchor="middle">structure.* rules</text>
  <rect x="230" y="74" width="180" height="30" fill="#fef3c7" stroke="#f59e0b" stroke-width="1.5" rx="8"/>
  <text x="320" y="88" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="600" fill="#1e293b" text-anchor="middle">lintLinks()</text>
  <text x="320" y="100" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#92400e" text-anchor="middle">link.* rules</text>
  <!-- Arrow -->
  <line x1="410" y1="50" x2="445" y2="50" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#ah-quality)"/>
  <!-- Step 3: Output -->
  <rect x="450" y="10" width="180" height="80" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.5" rx="10"/>
  <text x="540" y="36" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="600" fill="#1e293b" text-anchor="middle">LintIssue[]</text>
  <text x="540" y="58" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">severity · message ·</text>
  <text x="540" y="74" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">blockId · optional fix</text>
  <!-- Divider -->
  <line x1="30" y1="130" x2="610" y2="130" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="6 4"/>
  <text x="320" y="155" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="600" fill="#64748b" text-anchor="middle">Consumed by</text>
  <!-- Consumer chips -->
  <rect x="40" y="180" width="160" height="60" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1" rx="8"/>
  <text x="120" y="206" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="600" fill="#1e293b" text-anchor="middle">Issues panel</text>
  <text x="120" y="224" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">editor sidebar</text>
  <rect x="240" y="180" width="160" height="60" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1" rx="8"/>
  <text x="320" y="206" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="600" fill="#1e293b" text-anchor="middle">Canvas badges</text>
  <text x="320" y="224" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">per-block icons</text>
  <rect x="440" y="180" width="160" height="60" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1" rx="8"/>
  <text x="520" y="206" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" font-weight="600" fill="#1e293b" text-anchor="middle">Headless / CI</text>
  <text x="520" y="224" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" fill="#64748b" text-anchor="middle">stored templates</text>
</svg>

The package has no opinion on UI. The editor's `useTemplateLint` composable lazy-imports `@templatical/quality`, runs all linters via the single `lintTemplate` call on debounced content changes, and feeds the merged issues stream into the **Issues** sidebar tab and the per-block canvas badges. `applyFix(issue)` runs each patch through the editor's existing block-update path so fixes land as proper undo entries.

## Install

::: code-group
```bash [npm]
npm install @templatical/quality
```
```bash [pnpm]
pnpm add @templatical/quality
```
```bash [yarn]
yarn add @templatical/quality
```
```bash [bun]
bun add @templatical/quality
```
:::

The package is an **optional peer** of `@templatical/editor`. Install it to turn on the Issues sidebar tab and canvas badges. Skip it and the editor stays lean — the dynamic import is gated and tree-shakeable, so the linter chunk never downloads.

::: tip CDN users
If you load Templatical via CDN, there's nothing to install. The editor's CDN bundle ships `@templatical/quality` as a separate code-split chunk that lazy-loads automatically when linting is enabled.
:::

## Wire into the editor

Pass `lint` to `init()` or `initCloud()`:

```ts
import { init } from "@templatical/editor";

const editor = init({
  container: "#editor",
  locale: "en",
  lint: {
    accessibility: {
      rules: {
        "a11y.img-missing-alt": "warning",   // soften from default 'error'
        "a11y.text-all-caps": "off",         // turn off entirely
      },
      thresholds: { minFontSize: 16 },
    },
    structure: {
      rules: { "structure.empty-column": "info" }, // demote to info
    },
    links: {
      rules: { "link.localhost-or-staging": "error" }, // promote before send
      nonProductionHosts: ["*.staging.*", "*.preview.*"],
    },
  },
});
```

The Issues tab and inline canvas badges appear automatically once the optional peer is resolved. When `lint.disabled === true` — or when every per-tool key (`accessibility`, `structure`, `links`) is `false` — the editor never lazy-loads the package: no chunk download, no UI surface.

Set any single linter to `false` to drop just that tool's rules:

```ts
init({ container: "#editor", lint: { links: false } });
```

## Quick links

- [Options](./options) — `disabled`, `locale`, per-tool `accessibility` / `structure` / `links` namespaces.
- [Severity & fixes](./severity-and-fixes) — severity model + how auto-fix patches land in the editor.
- [Headless usage](./headless-usage) — `lintTemplate` + validating stored templates in CI / server save handlers.
- [Contributing locales](./contributing-locales) — adding rule messages + vague-text dictionaries.
- [Accessibility linter](./accessibility/) — what it catches, rule catalog.
- [Structure linter](./structure/) — what it catches, rule catalog.
- [Links linter](./links/) — what it catches, rule catalog.

---

# Links linter

Source: https://docs.templatical.com/quality/links/

`lintLinks(content, options?)` is the URL-hygiene checker inside [`@templatical/quality`](../). It walks every URL in the template — anchors inside rich text, `button.url`, `image.linkUrl`, `video.url`, `menu.items[].url`, `social.icons[].url` — and flags URL-shaped data that's broken, dangerous, or accidentally pointing at the wrong environment.

## Purpose

Email URLs are a long-tail bug source:

- A `javascript:` href slips into a template from an import; the render-time sanitizer drops it for safety, but the author never finds out the link is broken until a recipient complains.
- A staging URL ships to production because nobody diffed the JSON before send.
- A malformed `mailto:` makes "Contact us" silently dead.
- A `tel:` with letters in it doesn't dial anywhere.
- A `data:` or app-deep-link `href` looks fine in JSON but no email client renders it.

These aren't content-quality issues (different audience from a11y) and they aren't tree-corruption issues (the JSON validates). They live in their own category.

## Interaction with editor security

The editor already ships two URL-scheme defenses:

- **`useRichTextLinkDialog.normalizeLinkUrl`** — the rich-text link dialog rejects URLs outside its safe-scheme allowlist (`http`, `https`, `mailto`, `tel`, `ftp`, `ftps`, `sms`, `xmpp`, `cid`) at insert time.
- **`sanitizeRichTextHtml`** — a render-time scrubber strips `javascript:` / `vbscript:` / `file:` (and other unsafe-scheme) `href` / `src` / `formaction` values from `paragraph.content` / `title.content` / `html.content` before they reach `v-html`.

Those are **security boundaries** — they prevent XSS by silently dropping dangerous values. `lintLinks` is an **authoring tool**: it surfaces the same values so the author can fix the JSON rather than have it stripped at render.

The two layers complement each other:

| Source of a `javascript:` link | `normalizeLinkUrl` | `sanitizeRichTextHtml` | `lintLinks` |
|---|---|---|---|
| User typing in the rich-text link dialog | ✅ blocks at insert | n/a (never reaches content) | n/a |
| Imported BeeFree / Unlayer / HTML / MJML / Topol / Stripo / Chamaileon / Easy Email Pro JSON | ❌ bypasses dialog | ✅ stripped at render | ✅ surfaces to author |
| Programmatic `setContent()` with bad anchor | ❌ | ✅ stripped at render | ✅ surfaces to author |
| `button.url`, `image.linkUrl`, `video.url`, `menu.items[].url`, `social.icons[].url` (structured fields, not HTML) | ❌ | ❌ sanitizer only scans rich-text HTML | ✅ surfaces to author |

So `lintLinks` is the **only authoring-time signal** for unsafe URLs in structured fields, and the **only feedback channel** when a stripped value would otherwise vanish silently from rich-text imports.

## API

```ts
import { lintLinks } from "@templatical/quality";

const issues = lintLinks(content); // options is an optional second argument
// issues: LintIssue[] — each entry has ruleId starting with "link."
```

Same signature as `lintAccessibility` and `lintStructure`. Same `LintOptions` shape. Same `LintIssue` return type. You can run all three linters independently or merge results.

In the editor, the `useTemplateLint` composable lazy-imports `@templatical/quality` and runs every linter on every (debounced) content change. Link issues appear in the **Issues** sidebar tab alongside accessibility and structure issues.

## Configuration

`lintLinks` reads its config under `LintOptions.links`. Set `links: false` to disable the entire linter without enumerating rules.

```ts
interface LinksLintOptions {
  rules?: Record<string, Severity>;
  nonProductionHosts?: string[];
}
```

### `links.rules`

Per-rule severity override for link rules:

```ts
lintLinks(content, {
  links: { rules: { "link.localhost-or-staging": "error" } },
});
```

### `links.nonProductionHosts`

| Default | `['localhost', '127.0.0.1', '0.0.0.0', '*.local', '*.staging.*', '*.dev.*']` |
|---|---|

Glob-style patterns matched against the URL host. `*` matches any run of characters (including `.`) — so `*.staging.*` matches `app.staging.example.com` and `*.local` matches `acme.local` or `a.b.c.local`. Patterns are anchored, so `*.local` does NOT match `acme.local-tools`. Case-insensitive.

Pass an empty array to silence `link.localhost-or-staging` without disabling the rule outright:

```ts
lintLinks(content, {
  links: { nonProductionHosts: [] },
});
```

Or extend / replace with your own patterns:

```ts
lintLinks(content, {
  links: {
    nonProductionHosts: [
      "*.preview.*",       // catch a vendor preview host
      "*.internal.acme.io" // catch internal staging
    ],
  },
});
```

The `DEFAULT_NON_PRODUCTION_HOSTS` constant is exported if you need to reference the baseline programmatically.

## Quick links

- [Rule catalog](./rule-catalog) — every link rule with severity, scope, and rationale.
- [Options](../options) — shared across every linter.
- [Severity & fixes](../severity-and-fixes) — how severity overrides land and the fix-application model.
- [Headless usage](../headless-usage) — validating stored templates in CI.
- [Contributing locales](../contributing-locales) — adding link rule messages.

---

# Link rule catalog

Source: https://docs.templatical.com/quality/links/rule-catalog

The 5 rules `lintLinks` ships. Each rule lives in `packages/quality/src/links/rules/`; severity is user-overridable per [Options](../options). All rules scan every URL source returned by `walkUrls` — anchors inside rich text plus `button.url`, `image.linkUrl`, `video.url`, `menu.items[].url`, `social.icons[].url`.

## Dangerous protocols

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `link.javascript-protocol` | error | — | Href starts with `javascript:` (or whitespace-padded / case-bypass variants like ` JaVaScRiPt:`). The editor's `sanitizeRichTextHtml` already strips these from rich-text content at render so they can't execute, and the rich-text link dialog blocks insertion — but values from imported JSON, programmatic API calls, or structured fields (`button.url`, `image.linkUrl`, `menu.items[].url`, `social.icons[].url`) sit in the template silently. This rule surfaces them at authoring time so the JSON gets fixed, not just rendered around. |

## Unsupported protocols

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `link.unsupported-protocol` | warning | — | Href uses an explicit protocol other than `http`, `https`, `mailto`, `tel`, or `sms`. Custom protocols (`ftp:`, `file:`, `data:`, app deep-links) typically don't work from email clients and indicate either a copy-paste mistake or an unsupported integration. Relative URLs and bare paths are ignored. `javascript:` is excluded — its own rule covers that. |

## Malformed URIs

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `link.malformed-mailto` | warning | — | `mailto:` href is empty, has no `@`, is missing the local or domain part, or the domain has no `.`. Query-string fragments (`?subject=…`, `?cc=…`, `?body=…`) are accepted. Multi-recipient `mailto:a@x.com,b@y.com` is accepted. Pragmatic — not a full RFC validator. |
| `link.malformed-tel` | warning | — | `tel:` href contains characters outside `+`, digits, spaces, dashes, parentheses, and dots. Most clients silently fail on bad `tel:` URIs (e.g. `tel:CALL-NOW`). |

## Environment leaks

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `link.localhost-or-staging` | warning | — | URL host matches the configured non-production host list. Default list catches `localhost`, `127.0.0.1`, `0.0.0.0`, `*.local`, `*.staging.*`, `*.dev.*`. Configurable via `LintOptions.links.nonProductionHosts` — see the [overview](./) for default extension / replacement patterns. Only fires on `http(s)` and `ftp(s)` URLs; mailto/tel/sms are skipped. |

## Auto-fixes

Each link rule is destructive (strip href / change protocol) and the right answer depends on intent — `javascript:alert(1)` may belong on a button that should be deleted, or it may be a typo for `mailto:`. Detection-only is safer than guessing. Headless callers can filter `LintIssue[]` by `ruleId.startsWith("link.")` and apply their own policy (block save, send to review queue, …).

---

# Options

Source: https://docs.templatical.com/quality/options

`lintTemplate`, `lintAccessibility`, `lintStructure`, and `lintLinks` all accept the same `LintOptions` shape. Every field is optional.

```ts
interface LintOptions {
  disabled?: boolean;
  locale?: string;
  accessibility?: false | AccessibilityLintOptions;
  structure?: false | StructureLintOptions;
  links?: false | LinksLintOptions;
}

interface AccessibilityLintOptions {
  rules?: Record<string, Severity>;
  thresholds?: Partial<LintThresholds>;
}

interface StructureLintOptions {
  rules?: Record<string, Severity>;
}

interface LinksLintOptions {
  rules?: Record<string, Severity>;
  nonProductionHosts?: string[];
}

type Severity = "error" | "warning" | "info" | "off";
```

The same object also gates the editor's `init({ lint })` config — every option here applies to every linter via the shared `useTemplateLint` composable.

Severity overrides and tool-specific knobs (`thresholds`, `nonProductionHosts`) are namespaced under their owning linter, so each linter only sees the config that's relevant to it.

## `disabled`

| Default | `false` |
|---|---|

When `true`:

- The editor **does not lazy-import** `@templatical/quality` — its chunk never downloads.
- The Issues sidebar tab is **not registered**.
- The inline canvas badges produce **no DOM**.

Use this when a tenant has explicitly opted out, or to keep the default OSS bundle minimal. There's no soft-disable — `disabled: true` is a complete, irreversible-per-instance shut-off.

::: tip Disabling every linter individually has the same effect
The editor treats `{ accessibility: false, structure: false, links: false }` as equivalent to `{ disabled: true }`: no chunk download, no sidebar tab, no canvas badges. So you don't need the global flag if every linter is already off.
:::

## `locale`

| Default (headless) | `'en'` |
|---|---|
| Editor | always matches `init({ locale })` |

Drives the message templates the linter returns (one file per locale per linter) and is accepted by the locale-aware accessibility rules (`a11y.link-vague-text`, `a11y.button-vague-label`, `a11y.img-linked-no-context`). Falls back to `en` when the locale (or its base language) isn't bundled.

```ts
// Headless — set explicitly
lintAccessibility(content, { locale: "de" });
lintStructure(content, { locale: "de" });
lintLinks(content, { locale: "de" });

// Editor — linter automatically follows the editor locale
init({ locale: "de" });
```

::: warning Editor mode ignores `lint.locale`
In editor mode the linter locale is **forced** to the editor's `locale` from `init({ locale })`. Setting `lint.locale` has no effect — it's overwritten on the way through.

Headless callers (`lintTemplate(...)` or `lintAccessibility(...)` / `lintStructure(...)` / `lintLinks(...)` directly) keep full control.
:::

::: tip Vague-text dictionaries are cross-locale
The dictionary is a union of every registered locale, so a German-locale email with an English `Click here` button still flags `a11y.link-vague-text` / `a11y.button-vague-label`, and a German `Jetzt kaufen` alt on an English-locale linked image still satisfies `a11y.img-linked-no-context`'s action-hint check. The `locale` option doesn't gate matching — it only drives message text.
:::

## `accessibility`

| Default | `{}` (linter enabled, defaults for every knob) |
|---|---|

Configuration for `lintAccessibility`. Set to `false` to disable the entire accessibility linter without enumerating its rules.

```ts
// Turn off the whole accessibility linter
lintAccessibility(content, { accessibility: false });
```

### `accessibility.rules`

| Default | `{}` |
|---|---|

Per-rule severity override for accessibility rules. Set a rule to `'off'` to disable it entirely. Set to a different severity to bend the default classification:

```ts
lintAccessibility(content, {
  accessibility: {
    rules: {
      "a11y.img-missing-alt": "warning",   // soften
      "a11y.text-all-caps": "off",         // disable
      "a11y.missing-preheader": "warning", // promote info → warning
    },
  },
});
```

Override keys use the full prefixed rule ID (`a11y.*`) — the same ID that appears on `LintIssue.ruleId`, so a value copied from an emitted issue pastes straight in. See the [accessibility rule catalog](./accessibility/rule-catalog) for default severities.

### `accessibility.thresholds`

| Default | See below |
|---|---|

Numeric knobs that some accessibility rules consult.

| Threshold | Default | Used by |
|---|---|---|
| `altMaxLength` | `125` | `a11y.img-alt-too-long` |
| `minFontSize` | `14` | `a11y.text-too-small` |
| `allCapsMinLength` | `20` | `a11y.text-all-caps` |
| `minTouchTargetPx` | `44` | `a11y.button-touch-target` |

Override one without losing the others — partial merging is built in:

```ts
lintAccessibility(content, {
  accessibility: { thresholds: { minFontSize: 16 } },
});
```

The `DEFAULT_A11Y_THRESHOLDS` constant is also exported if you need to reference the baseline programmatically.

## `structure`

| Default | `{}` |
|---|---|

Configuration for `lintStructure`. Set to `false` to disable the entire structure linter.

```ts
lintStructure(content, { structure: false });
```

### `structure.rules`

Per-rule severity override for structure rules:

```ts
lintStructure(content, {
  structure: {
    rules: {
      "structure.empty-column": "info",    // demote warning → info
    },
  },
});
```

Keys use the `structure.*` prefix. See the [structure rule catalog](./structure/rule-catalog) for default severities.

## `links`

| Default | `{}` |
|---|---|

Configuration for `lintLinks`. Set to `false` to disable the entire links linter.

```ts
lintLinks(content, { links: false });
```

### `links.rules`

Per-rule severity override for link rules:

```ts
lintLinks(content, {
  links: {
    rules: {
      "link.localhost-or-staging": "error", // promote to error before send
    },
  },
});
```

Keys use the `link.*` prefix. See the [links rule catalog](./links/rule-catalog) for default severities.

### `links.nonProductionHosts`

| Default | `['localhost', '127.0.0.1', '0.0.0.0', '*.local', '*.staging.*', '*.dev.*']` |
|---|---|

Glob-style patterns matched against the URL host. `*` matches any run of characters (including `.`) — so `*.staging.*` matches `app.staging.example.com` and `*.local` matches `acme.local` or `a.b.c.local`. Patterns are anchored, so `*.local` does NOT match `acme.local-tools`. Case-insensitive.

```ts
lintLinks(content, {
  links: { nonProductionHosts: ["*.preview.*"] },
});

// Or silence the rule without disabling it:
lintLinks(content, { links: { nonProductionHosts: [] } });
```

`DEFAULT_NON_PRODUCTION_HOSTS` is exported as the baseline. See the [links overview](./links/) for more.

---

# Severity & fixes

Source: https://docs.templatical.com/quality/severity-and-fixes

Every linter shares the same severity model and patch shape, so this page covers `lintTemplate`, `lintAccessibility`, `lintStructure`, and `lintLinks` together.

## Severity model

Every rule emits a `LintIssue` with one of four severities:

| Severity | Meaning | UI |
|---|---|---|
| `error` | Hard failure. Recipient may be excluded, or the template is structurally corrupt. | Red dot on canvas, "Errors" group in the Issues panel. |
| `warning` | Likely problem — fix unless you know better. | Yellow dot, "Warnings" group. |
| `info` | Recommendation; not a defect. | No canvas badge, "Info" group. |
| `off` | Override — disables the rule entirely. | Nothing. |

Severity is configurable per rule via the linter's own `rules` map — `accessibility.rules`, `structure.rules`, `links.rules`. Each rule's documented default is just the baseline.

```ts
lintAccessibility(content, {
  accessibility: { rules: { "a11y.text-all-caps": "off" } },
});
```

## Auto-fix

Some rules ship a `fix` patch that the user can apply with one click from the Issues panel. Each patch implements:

```ts
interface LintPatch {
  description: string;
  apply: (ctx: LintPatchContext) => void;
}

interface LintPatchContext {
  updateBlock: (blockId: string, patch: Partial<Block>) => void;
  updateSettings: (patch: Partial<TemplateSettings>) => void;
  removeBlock: (blockId: string) => void;
}
```

In the editor, `apply` runs through the existing `editor.updateBlock` / `editor.updateSettings` / `editor.removeBlock` path — which is wrapped by the history interceptor — so each fix lands as its own undo entry. Users can press Cmd/Ctrl+Z to revert a fix without losing surrounding work.

Headless callers can construct their own `LintPatchContext` and apply patches programmatically:

```ts
import { lintTemplate } from "@templatical/quality";

const fixable = lintTemplate(content).filter((i) => i.fix);

for (const issue of fixable) {
  issue.fix!.apply({
    updateBlock: (id, patch) => mutateBlock(content, id, patch),
    updateSettings: (patch) => Object.assign(content.settings, patch),
    removeBlock: (id) => removeBlockFromTree(content, id),
  });
}
```

## Which rules ship a fix?

See the **Auto-fix** column in each catalog. Today's auto-fixable rules:

- Accessibility — `a11y.img-decorative-needs-empty-alt`, `a11y.link-target-blank-no-rel`, `a11y.text-justified`.
- Structure — `structure.empty-section`.
- Links — none. Every link rule is destructive to auto-fix (strip the href, change the protocol) and the right answer depends on intent.

Auto-fixes are added conservatively — only when the right answer is unambiguous. `structure.empty-column`, for example, has no auto-fix because removing an empty column requires changing the section's `columns` layout, and the right answer (merge into a sibling vs. drop the section vs. change the layout key) depends on intent.

---

# Structure linter

Source: https://docs.templatical.com/quality/structure/

`lintStructure(content, options?)` is the data-integrity checker inside [`@templatical/quality`](../). It walks the `TemplateContent` block tree and flags shapes that indicate corruption — duplicate IDs, sections whose `columns` layout doesn't match their `children` array, nested sections (the renderer rejects them), and empty sections / columns.

## Purpose

Most "is this template OK?" tooling cares about content quality (alt text, contrast). Structure rules cover a different problem: **can this JSON safely render at all?** Importers (BeeFree, Unlayer, HTML, MJML, Topol, Stripo, Chamaileon, Easy Email Pro) and custom server-side editors can produce blocks the editor would never produce — orphan column entries, missing block fields, layout/children mismatches. By the time they reach the renderer they're usually too late to recover from cleanly.

The structure linter catches these before save / before send:

- **Duplicate block IDs.** Tree traversal, undo/redo, and selection all assume IDs are unique. A duplicate ID silently corrupts every operation that targets a block by ID.
- **Section column mismatch.** A section with `columns: "2-1"` expects `children.length === 2`. If `children` has one or three inner arrays, the layout is broken — usually a UI bug or a stale import.
- **Nested section.** The renderer rejects sections inside columns. If one ends up there, MJML output silently drops it.
- **Empty section.** A section with no blocks renders as a blank table row — wasted whitespace, sometimes a visible padding gap.
- **Empty column.** A multi-column section with one empty column renders awkwardly in most clients and almost always means the author intended fewer columns.

These rules are deterministic and locale-agnostic — they fire on JSON shapes, not phrases. Only the message text needs translating.

## API

```ts
import { lintStructure } from "@templatical/quality";

const issues = lintStructure(content); // options is an optional second argument
// issues: LintIssue[] — each entry has ruleId starting with "structure."
```

Same signature as `lintAccessibility`. Same `LintOptions` shape. Same `LintIssue` return type. You can run both linters independently or merge results.

Structure-specific config lives under `LintOptions.structure`. Set `structure: false` to disable the linter entirely.

```ts
lintStructure(content, {
  structure: { rules: { "structure.empty-column": "info" } },
});
```

In the editor, the `useTemplateLint` composable lazy-imports `@templatical/quality` and runs both linters on every (debounced) content change. Structure issues appear in the **Issues** sidebar tab alongside accessibility issues.

## Quick links

- [Rule catalog](./rule-catalog) — every structure rule with severity, rationale, and an auto-fix note.
- [Options](../options) — shared across both linters.
- [Severity & fixes](../severity-and-fixes) — how the severity model works and how auto-fix patches are applied.
- [Headless usage](../headless-usage) — validating stored templates in CI.

---

# Structure rule catalog

Source: https://docs.templatical.com/quality/structure/rule-catalog

The 5 rules `lintStructure` ships. Each rule lives in `packages/quality/src/structure/rules/`; severity is user-overridable per [Options](../options).

## Tree integrity

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `structure.duplicate-block-id` | error | — | Two or more blocks share the same `id`. Tree traversal, undo/redo, selection, and every block-by-ID operation rely on uniqueness; a duplicate corrupts them silently. Usually a sign of a broken import or a clone path that forgot to regenerate IDs. |
| `structure.nested-section` | error | — | A section block sits inside another section's column. The renderer rejects this — sections cannot nest — so the inner section silently drops out of the MJML output. Catches importer bugs and copy-paste accidents. |

## Section layout

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `structure.section-column-mismatch` | error | — | The section's `columns` value implies a column count that doesn't match `children.length`. `"1"` expects 1 inner array, `"2"`/`"2-1"`/`"1-2"` expect 2, `"3"` expects 3. A mismatch means either the layout key or the children array is wrong — both yield broken render output. The editor's section toolbar rebalances `children` automatically when the layout changes; this rule catches data that bypassed the toolbar (imports, manual JSON edits, stale snapshots). |

## Content presence

| Rule | Default severity | Auto-fix | What it checks |
|---|---|---|---|
| `structure.empty-section` | warning | yes | A section has no columns, or every column is empty. Renders as a blank table row in most clients — wasted whitespace and an occasional visible padding gap. Auto-fix removes the section. |
| `structure.empty-column` | warning | — | A multi-column section has at least one column with no blocks. Renders awkwardly (uneven gaps, collapsed columns on mobile) and almost always means the author intended fewer columns. No auto-fix because the right answer (merge into a sibling vs. drop the section vs. change the `columns` layout) depends on intent. |

---

# Showcase & use cases

Source: https://docs.templatical.com/showcase

Templatical is purpose-built to be the email-composition layer of a larger product. This page describes the patterns it fits best — and is the place where teams who ship Templatical in production can list themselves.

## Common use cases

These are the integration patterns we see most often. Each describes how Templatical typically fits, what's worth knowing up front, and which packages are most relevant.

### Transactional email tool

You're building (or extending) a transactional email API — Postmark, Resend, SES wrappers, internal sending platforms. Customers connect their codebase, then need a way to design and version the templates the API sends.

**Templatical fits because:**
- Templates are JSON — versionable, diffable, AI-friendly, easy to store next to your customers' data.
- MJML output renders consistently across Outlook, Gmail, Apple Mail, and the long tail of clients.
- The renderer produces MJML in Node; you compile that MJML to HTML with any MJML library (or `@templatical/template-tools render --format html`) at send time, without a vendor-hosted render API.

**Likely setup:** [`@templatical/editor`](https://www.npmjs.com/package/@templatical/editor) for the visual editor in your dashboard, [`@templatical/renderer`](https://www.npmjs.com/package/@templatical/renderer) on your backend for MJML, then compile to HTML with any MJML library before sending.

### Newsletter or marketing-email SaaS

You're building a Mailchimp-style product, an automation tool, or a creator-newsletter platform. Email composition is one of several features your customers need.

**Templatical fits because:**
- Drop-in editor mounts with one function call — no rewrite of your existing dashboard.
- Theming via design tokens means your customers' emails feel native to your brand, not Templatical's.
- Display conditions and merge tags are built in — important for personalization-heavy newsletter use.
- Cloud is the managed adapter for AI rewrite and real-time collaboration, plus hosted storage — comments, version history, and saved blocks are OSS BYO providers on `init()` (or source-available self-host).

**Likely setup:** [`@templatical/editor`](https://www.npmjs.com/package/@templatical/editor) embedded in your customer dashboard, [`@templatical/renderer`](https://www.npmjs.com/package/@templatical/renderer) for MJML then any MJML library for HTML, optional Cloud tier for AI/collab.

### CRM or marketing automation product

You're building a CRM, a sales-engagement tool, or a marketing automation platform where customers send branded emails as one workflow among many.

**Templatical fits because:**
- It's not a separate "email designer" tab — it embeds inline alongside contact records, campaign builders, and automation flows.
- Custom blocks let you surface your product's data (contact fields, deal info, calculated values) as first-class content blocks customers can drop into emails.
- Real-time collaboration lets sales and marketing teams co-edit campaign templates without clobbering each other.

**Likely setup:** [`@templatical/editor`](https://www.npmjs.com/package/@templatical/editor) with `customBlocks` registered for your domain entities, plus [`@templatical/core/cloud`](https://www.npmjs.com/package/@templatical/core) for collab if you want it; comments are a `comments` provider on `init()`.

### Internal email composer

You're not building a customer-facing product — you need a controlled tool for your team to design transactional and marketing emails, with the JSON output stored in your own systems.

**Templatical fits because:**
- Self-hostable in full. The OSS SDK has no required cloud dependency.
- Multilingual (English, German, Portuguese, Spanish, Catalan, French, Dutch, Japanese) out of the box, with a clean path to add more locales for international teams.
- TypeScript-strict end-to-end, which makes it easy to wire into existing internal tooling and codegen pipelines.

**Likely setup:** [`@templatical/editor`](https://www.npmjs.com/package/@templatical/editor) inside your internal portal, [`@templatical/renderer`](https://www.npmjs.com/package/@templatical/renderer) in your job runners or send pipeline.

### Headless / programmatic template generation

You're not embedding a visual editor at all — you want to generate templates from data programmatically, version them in source control, and render them through a deterministic pipeline.

**Templatical fits because:**
- The block system has factory functions (`createTitleBlock`, `createImageBlock`, …) and full TypeScript types — you can build templates entirely in code with full type safety.
- The renderer is a pure function with no DOM dependency. Run it in serverless, edge, or Node.

**Likely setup:** [`@templatical/types`](https://www.npmjs.com/package/@templatical/types) for factories and types, [`@templatical/renderer`](https://www.npmjs.com/package/@templatical/renderer) for MJML output, no editor needed.

See [Programmatic Templates](/guide/programmatic-templates) for a full walkthrough of this pattern.

## Built with Templatical?

If your team ships Templatical in production, we'd love to feature you here. Open a pull request adding your entry, or [start a discussion](https://github.com/templatical/sdk/discussions) and we'll do it together.

What we list:
- A short product description (one sentence)
- Your logo and a link
- Optional: a one-paragraph note on how you use Templatical (which packages, which features, anything notable)

Until a production team lists itself, the public demo is the [playground](https://play.templatical.com).

::: tip Sponsorship & support
If your company depends on Templatical, you can also support development directly on [GitHub Sponsors](https://github.com/sponsors/orkhanahmadov). Sponsorship is independent from being listed here — listing is free, and it stays free.
:::
