Skip to content

Localized templates ​

Language versions keep every translation of one email in one template: a shared block tree, plus l10n layers with each language's text, property overrides and hidden blocks, for the languages in settings.locales. Authors add and edit versions in the editor and fill them with Translate through your TranslationProvider; resolveLocale and --locale project, check and render one version.

Language versions share one structure. For independent block trees per language, keep separate templates and switch them with editor.load(id).

Setup ​

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

const editor = await init({
  container: "#editor",
  localization: { locales: ["en", "de", "fr"] },
});

localization.locales lists the BCP 47 tags authors can add. The template's settings.locale is its default language; a blank template starts in English ("en"). Configuring the list writes nothing into the template: the Languages menu appears in the header, and an author adds a version from it. For a static page without a bundler, mount from the CDN as in the Quick Start and pass the same localization key.

Add translation for Translate and localization.doNotTranslate for brand names; see Translating. The editor's UI language (init({ locale })) is independent of template languages; see Internationalization. Existing templates need no migration; see Compatibility.

Adding a language ​

Open Languages, choose Add language, then a language; the submenu offers the configured languages the template lacks. Labels name each language in itself, for example de as Deutsch and fr as Français, wherever they show {language} or {default}. Select the new version in the menu to edit it: the language bar under the header reads Editing {language}. Back to {default} in the bar, or the default row in the menu, returns to the default language. Each version's More actions for {language} button offers Make default language (Default language) and Remove {language}…, which asks for confirmation and can be undone.

Text and property edits in a language view change only that language. When a translatable field has a default value, an edit stores text with its sourceHash; when the default has no value, such as an absent preheader, the edit stores a set override, which isn't tracked for staleness. An edit equal to the default leaves no override. For an overridden or translated field, the settings panel shows a language pill and a Reset to {default} icon that restores only that field from the default and keeps sibling overrides. The status badge’s Reset changes clears all of that block’s set overrides; it keeps its text translations. Hide in {language} in the block's settings leaves a ghost block on the canvas; Show restores it, and the renderer omits hidden blocks.

Add, move, delete, duplicate, paste and drag change the shared tree for every language. The language bar shows Structure changes apply to every language. once per session per template. A version in settings.locales but absent from localization.locales stays selectable, read-only and marked Not enabled: value edits and structure changes are refused. Selecting a block there still takes its lock for that language, so with collaboration a reviewer in the read-only view and an author editing another language can hold the same block at once.

The active language is view state and isn't saved; every session starts on the default. editor.getLocale(), editor.setLocale(tag) and init({ onLocaleChange }) read, select and report it; an unknown version throws SdkError with code unknown_locale. See Editor API.

Languages imported from Beefree, Unlayer and Topol use the same layers. Every importer accepts defaultLocale?: string for a source that declares no default, and template-tools import --default-locale <tag> passes it from the CLI; an invalid tag there exits 2. An importer ignores a declared default or defaultLocale that isn't a language tag, warns, and falls back to the next valid value or "en". Inspect report.warnings for dropped languages, then add the imported languages to localization.locales to edit them. Without the editor, write versions with the locale operations or with strings import (Translating).

Translating ​

Pass a TranslationProvider as init({ translation }) next to localization; without it there is no Translate UI. In an enabled language view, Translate {count} fields in the language bar sends the missing and out-of-date fields (the gaps), so current translations and manual edits survive; it's hidden when there are no gaps. Translate everything again… sends every eligible field after a confirmation that names the field count and says that manual edits are replaced; it's disabled when no field is eligible. A block's status badge offers Translate for missing text and Re-translate for out-of-date text or differing merge tags.

Translate masks merge tags, logic tags and the terms in localization.doNotTranslate (for example ["Sable"]) as numbered placeholders, and applies a returned field only when it carries exactly the placeholders it was sent; any other field keeps its previous value and is reported. HTML blocks, empty defaults, set overrides and hidden blocks aren't sent: translate HTML blocks by hand. The translation contract defines the placeholders, batch limits, errors and the DeepL settings.

While a run lasts, the language bar shows Translating {done} of {total}… with Cancel, and the editor stays editable. Fields returned before Cancel or an error apply together as one undo step; a run that changes nothing records nothing. With collaboration, a run is an ordinary edit, and blocks another session holds in that language or structurally are skipped. The dismissible result notice reports translated fields, fields kept because their tags changed, fields not returned, blocks skipped because someone is editing them, and the translation service's error message. Dismiss it to see the counts again; changing the view clears it.

AI chat, Fix with AI, Rewrite and Write alt text edit the active language; see AI in language views.

To exchange strings as files with a translation service or a translator, strings export writes a language's gaps as JSON and strings import applies the translated file:

bash
npx -y @templatical/template-tools strings export template.json --locale de --out strings.de.json
npx -y @templatical/template-tools strings import template.json strings.de.json --json

Set each entry's text and keep its id, sourceHash and placeholders unchanged. An out-of-date entry has no text: its old translation is in previous, which import never reads, so translate it again from source. Import applies every string whose sourceHash still matches the current default and whose placeholders come back exactly, writes the accepted strings, and exits 1 when any string was rejected. Both commands refuse the default language, and import refuses a file exported before another language became the default. Pass the same --do-not-translate and --definitions files to both commands; see Strings.

In code, the same round trip is localeStrings → JSON → applyLocaleStrings from @templatical/types. This complete TypeScript example extracts the gaps with Sable protected, simulates a translator's JSON edit and applies the returned locale operations; pass the same doNotTranslate list to both helpers. localeStrings returns an out-of-date entry's stored translation in text, and applyLocaleStrings stamps every text it receives with the extraction-time hash, so applying that entry unchanged marks the field current, as Keep does. Drop text from out-of-date entries before handing them to a translator, as the example does by moving it to previous, or use strings export. With only: "gaps", only out-of-date entries carry text; with only: "all", localeCoverage(content, locale).stale lists them. A default edited while the file is out makes that string out of date. The pure helpers don't sanitize returned HTML: sanitize it, keeping the placeholders, before applying it.

ts
import {
  applyLocaleStrings,
  applyOperations,
  createDefaultTemplateContent,
  createTitleBlock,
  localeStrings,
  resolveLocale,
  SYNTAX_PRESETS,
} from "@templatical/types";

const original = createDefaultTemplateContent("Arial", { locale: "en" });
original.blocks.push(
  createTitleBlock({
    id: "greeting",
    content: "<p>Hello {{first_name}}, welcome to Sable</p>",
  }),
);
const base = applyOperations(original, [{ op: "addLocale", locale: "de" }]);
const syntax = SYNTAX_PRESETS.liquid;
const doNotTranslate = ["Sable"];
const extracted = localeStrings(base, "de", {
  syntax,
  only: "gaps",
  doNotTranslate,
});
const json = JSON.stringify({
  sourceLocale: "en",
  targetLocale: "de",
  // With only: "gaps", only an out-of-date entry has `text`: its stored
  // translation. Move it to `previous`, as `strings export` does, so an entry
  // returned untouched can't be applied as current.
  strings: extracted.strings.map(({ text, ...field }) =>
    text === undefined ? field : { ...field, previous: text },
  ),
  tokens: extracted.tokens,
});

// Send this JSON to a translator, who sets each entry's `text`.
// Keep ids, source hashes and tokens unchanged.
const file: {
  sourceLocale: string;
  targetLocale: string;
  strings: { id: string; text?: string; sourceHash: string }[];
  tokens: Record<string, string[]>;
} = JSON.parse(json);
file.strings[0].text = '<p>Hallo <x id="1"/>, willkommen bei <x id="2"/></p>';
// Skip entries without `text`, as `strings import` does.
const translated = file.strings.flatMap(({ id, text, sourceHash }) =>
  text === undefined ? [] : [{ id, text, sourceHash }],
);
const { operations, rejected } = applyLocaleStrings(
  base,
  file.targetLocale,
  translated,
  { syntax, tokens: file.tokens, doNotTranslate },
);
if (rejected.length) throw new Error(JSON.stringify(rejected));
const content = applyOperations(base, operations);
const german = resolveLocale(content, "de");
// Save `content`: it keeps every language. `german` is the German version.
console.log(JSON.stringify(content), JSON.stringify(german));

Checking a language ​

The header's language button names the active language; in a language view with gaps it adds a warning icon and the number of gaps, with "{missing} missing, {stale} out of date" in its tooltip. Its accessible name reads the language name, then the visible gap count, the language tag and the same breakdown; without gaps it stays, for example, "Deutsch (de)". The language bar shows the same counts or Complete. A block with a localization state carries an icon badge in its top-right corner (top-left in a right-to-left view); its tooltip and accessible name give the label below. Select the badge, or Jump to block on a localization finding in the Issues panel, to open its popover. On a short selected block in a narrow canvas, the action bar sits beside the badge slot, leaving the badges and their focus rings visible.

Badge labelMeaningActions
Hidden inThe block isn't in this language's emailShow
Not translatedA field has no text in this language; the email shows the default textTranslate
Out of dateThe default text changed after the translationRe-translate, Keep, Use
Merge tags differA current translation's merge tags differ from the default textRe-translate, Use
Changed inProperties are overridden in this languageReset changes
Out of date in {count} languagesIn the default view: this block's translations are out of dateOpen, per language

A block in several states shows the first one in this order; with several fields in one state its label counts them ("{count} not translated"). Translate and Re-translate need a translation provider. Badges don't show in preview, and a layout's blocks have none (Layouts). template-tools validate --locale <tag> and lintTemplate(content, { contentLocale }) report missing, out-of-date and merge-tag findings as localization.untranslated, localization.stale and localization.merge-tags-differ, described below; see Check your work.

Missing values ​

A missing translation falls back to the default-language value. The block shows Not translated, coverage reports the field as missing, and localization.untranslated reports a warning. Empty defaults and hidden blocks don't count as missing. HTML block content does count, although Translate and strings export don't send it: the language bar counts it as missing without offering Translate {count} fields for it, and the warning stays until you translate it by hand in the language view. A deliberate empty translation is a value and doesn't fall back.

Out-of-date values ​

Each translation stores the sourceHash of the default value it was written against. When the default changes, the translation is Out of date and localization.stale reports it. Re-translate translates it again. Keep keeps the text and stores the current hash, even when the changed default now equals the translation; coverage then counts it as translated. Use {default} removes the translation so the default shows. None of them changes sibling overrides, and hiding and showing a block keeps its text. In the default view, Out of date in {count} languages marks the affected blocks. Overrides in set have no source hash. Types gives the hash format.

Merge tags ​

Keep the default's merge tags, including repeated ones, in each translated field: localization.merge-tags-differ compares them as a multiset, and the editor offers the same merge-tag list in every language. A current translation whose tags differ shows Merge tags differ. Its Re-translate, with a provider, sends every eligible text field of that block. If this would replace another current translation in the block, the popover first asks for confirmation with the field count and explains that manual edits are replaced. Confirming applies the translation as one undo step; without another current translation to replace, Re-translate starts at once. During confirmation, Escape returns to Re-translate; a second Escape closes the popover. Use {default} removes only the mismatched translations. Missing and out-of-date text take priority over this state. The rule is a warning, but CLI validation exits 1 on it, even with valid: true; missing and out-of-date translations alone don't change the exit code.

Rendering a language ​

renderToMjml(content, { locale: "de" }), editor.toMjml({ locale: "de" }), editor.toHtml({ locale: "de" }) and template-tools render template.json --locale de render one version. Without locale they render the default, whatever view the editor shows; an unknown version fails with unknown_locale. The projected language and direction set MJML lang and dir. Local MJML export needs the optional @templatical/renderer peer, and HTML needs a render.toHtml or render.compileMjml provider; see Rendering & Export.

With @templatical/types and @templatical/renderer installed, this complete TypeScript example adds German, writes its text and direction, checks coverage and renders German MJML:

ts
import {
  applyOperations,
  createDefaultTemplateContent,
  createTitleBlock,
  localeCoverage,
  resolveLocale,
  sourceHash,
  type TemplateContent,
} from "@templatical/types";
import { renderToMjml } from "@templatical/renderer";

const title = createTitleBlock({ content: "Welcome" });
const original: TemplateContent = createDefaultTemplateContent("Arial", {
  locale: "en",
});
original.blocks.push(title);
const content = applyOperations(original, [
  { op: "addLocale", locale: "de" },
  {
    op: "updateBlockLocale",
    blockId: title.id,
    locale: "de",
    text: {
      content: { value: "Willkommen", sourceHash: sourceHash(title.content) },
    },
  },
  { op: "updateSettingsLocale", locale: "de", set: { direction: "ltr" } },
]);
const german = resolveLocale(content, "de");
const coverage = localeCoverage(content, "de");
if (coverage.missing.length > 0 || coverage.stale.length > 0) {
  throw new Error(
    `German needs translating: ${[...coverage.missing, ...coverage.stale].join(", ")}`,
  );
}
const mjml = await renderToMjml(content, { locale: "de" });
console.log(german.settings.locale, mjml);

resolveLocale(content, locale) is the projection behind every render: it applies text, overrides and settings, drops hidden blocks, strips the localization fields and sets settings.locale to the projected language. Missing values stay at the default. Save the original content, which keeps every version, not the projection. Preview shows the active language with the layout composed before resolvePreview runs, and switching language discards the previous language's result.

A custom block with any text or set entry in the requested language loses its cached default-language renderedHtml in the projection, so it renders through renderCustomBlock; without one it's omitted rather than shown in the default language. The editor's export renders it in the requested language. See Custom block rendering.

Data shape ​

settings.locale is the default language. settings.locales?: string[] lists the other versions, without duplicates or the default. Every key in block.l10n and settings.l10n must be in that list.

MemberContract
block.l10n[locale].textRecord<string, LocalizedText>; each value is { value: string; sourceHash: string }
block.l10n[locale].setRecord<string, unknown> for URLs, images, styling and other overrides; null means the property is absent in that language
block.l10n[locale].hiddentrue omits the block and its descendants from this language
settings.l10n[locale]SettingsLocaleLayer: optional text (preheaderText) and set (such as direction, fonts and colors), without hidden

Language tags must match ^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$: 2–3 ASCII letters, followed by zero or more hyphen-separated subtags of 2–8 ASCII letters or digits. Forms beginning with x-… or i-… are rejected because their language segment has one letter; grandfathered forms such as no-bok and zh-min-nan match and are accepted.

A field path joins property names with .; at an array, the next segment is the element's id, never its index: items.nav-shop.text, rows.row-one.cells.cell-one.content, fieldValues.heading, styles.padding. A field id is <blockId>/<path> or settings/<path>. Menu item, social icon, table row and table cell ids must not contain . or /: validateTemplate reports them, and addBlock, updateBlock and setContent refuse an operation that adds one (a new block, item, row or cell) with invalid_operation. An id already in the template stays editable, so a template saved with one can still be edited; rename it to localize that element and to pass validation.

text holds human-readable fields only: the TRANSLATABLE_FIELDS of each block type (content, alt text, button text, countdown labels, menu item text, table cell content), the preheader, and custom text and textarea fields. Everything else, such as URLs, image sources, sizes and colors, is a set override. A path is in at most one of text and set. set can't replace section columns or whole built-in lists (items, rows, rows.<id>.cells, icons); override their element leaves instead. Empty layers and members are omitted, never stored as {} or []. Types has the declarations and path rules.

Layouts ​

An init({ layout }) shell is composed around the template, outside it, so its text isn't part of the template. A layout can carry its own settings.locales, block l10n and settings.l10n, including the wrapper children beside the slot, independent of the template's languages. Preview, toMjml(), toHtml(), the test-email preview and renderToMjml(content, { layout, locale }) project it with resolveLocale(layout, locale, { missing: "default" }) before composing; a language the layout lacks shows the layout's default.

A language can't hide the layout's slot or a wrapper that contains it, because the layout couldn't compose that language: updateBlockLocale refuses hidden: true on them with invalid_operation, moveBlock and addBlock refuse placing the slot under a block a language hides, and validateTemplate reports such a layer. Hiding any other layout block works as in a template.

The editor never edits the layout. Its blocks have no selection, lint or status badge, Translate doesn't send them, and the language counts don't include them. Translate the layout file instead, for example with the CLI:

bash
npx -y @templatical/template-tools edit layout.json --op '{"op":"addLocale","locale":"de"}' --json
npx -y @templatical/template-tools strings export layout.json --locale de --out layout.de.json
npx -y @templatical/template-tools strings import layout.json layout.de.json --json
npx -y @templatical/template-tools validate layout.json --locale de --json

Set each entry's text in layout.de.json between export and import, as in Translating.

Default language ​

settings.locale is the default language: the text every version falls back to and is translated from. To promote a version, open Languages, select the version's More actions for {language} and choose Make default language. The confirmation, Make {language} the default language?, counts the fields that have no text in that language, when there are any: "{count} fields have no {language} text and keep their {default} text. Translate them first. {default} keeps a copy; later {language} edits mark it out of date." Make default applies the change as one undo step, switches the view to the new default and reports "{language} is now the default language."; undoing it removes that notice. The item is disabled, with the reason under it, while the current default isn't in localization.locales ("Add {default} to the enabled languages first"), because the old default would become a version nobody can edit or promote back; and while blocks are hidden in that language ("Show the {count} blocks hidden in {language} first"). It isn't offered in a read-only editor or for a version that isn't enabled.

The change is one operation, { "op": "setDefaultLocale", "locale": "de" }:

  • German's text and overrides become the base. English takes German's place in settings.locales, with its differing values in an English layer; text German had translated to the same value stays a current English translation.
  • Before the change, a field without German text falls back to English, so the German email shows English there. defaultLocaleGaps(content, "de", definitions?) lists those field ids before the change. After the change, the German base keeps that English text, and English keeps its own current copy of those fields: a later German edit marks English out of date and leaves the English email unchanged. After the change, nothing flags that the German base still contains English text. Translate the gaps into German first, with Translate or strings export and strings import, until defaultLocaleGaps(content, "de") returns []. An empty default, such as a decorative image's alt: "", has no text to keep and isn't a gap. Translate and strings export don't send HTML blocks: translate HTML blocks by hand in the German view.
  • Other languages keep their state against the new base: current text is re-stamped and stays current; out-of-date text keeps its stored hash and stays out of date, unless that hash equals the promoted German value, in which case it becomes current.
  • A version with hidden blocks is refused with invalid_operation; localeHiddenBlocks(content, "de") lists the blocks to show first. An unknown version is refused with invalid_operation too, and with collaboration any other session's lock refuses the change with locked_by_others.

updateSettings can't change settings.locale while versions exist, and AI in a language view can't run setDefaultLocale. Operations gives the re-stamped hashes, removed optional fields, the inverse and the refusal messages. Check the result: the saved file has settings.locale "de" with "en" in settings.locales, template-tools validate template.json --json reports valid: true, and validate template.json --locale en --json no longer lists those fields as localization.untranslated: both gaps and text German translated to the English value keep current English entries.

For custom-block gaps, pass definitions to createSetDefaultLocaleOperation(content, "de", definitions) and apply the returned operation. Its customTextFields captures the declared text/textarea paths for deterministic collaboration. The editor captures its registered definitions automatically; CLI callers pass edit --definitions definitions.json. A bare operation without this metadata only discovers custom paths that already have a text entry.

Comments ​

Comment threads are shared across languages. Every new comment and reply sends CommentInput.locale, the language of the view it was written in, the default's tag included; your comments provider stores it and returns Comment.locale. A comment without a tag, saved before language versions existed or by a comments backend that doesn't store locale, counts as the template's current default language and follows a change of the default; a tagged comment keeps its tag. With localization configured, every comment and reply shows a language badge, and This language in the comments panel shows the threads whose root or any reply is in the active language, with all their messages and badges. The filter is view state and isn't saved.

Subject lines ​

A template has no subject field: the subject line belongs to the host that sends the email, so Translate and strings export don't include it. Translate it with the same TranslationProvider as one text field: mask its merge tags and protected terms with maskTags, send it, and restore the answer with unmaskTags, which returns null when a placeholder is missing or changed. This complete TypeScript example needs @templatical/types and @templatical/ai-kit; replace the mock with the provider you pass to init({ translation }):

ts
import { maskTags, SYNTAX_PRESETS, unmaskTags } from "@templatical/types";
import type { TranslationProvider } from "@templatical/types";
import { createMockTranslationProvider } from "@templatical/ai-kit/testing";

// Replace the mock with the provider you pass to init({ translation }).
const translation: TranslationProvider = createMockTranslationProvider({
  translations: {
    de: { subject: '<x id="1"/>, Ihre <x id="2"/>-Bestellung ist unterwegs' },
  },
});

const subject = "{{first_name}}, your Sable order is on its way";
const masked = maskTags(subject, SYNTAX_PRESETS.liquid, "text", {
  doNotTranslate: ["Sable"],
});
const result = await translation.translate(
  {
    templateId: null,
    sourceLocale: "en",
    targetLocale: "de",
    fields: [{ id: "subject", text: masked.text, format: "text" }],
  },
  new AbortController().signal,
);
const returned = result.fields.find((field) => field.id === "subject");
const germanSubject = returned
  ? unmaskTags(returned.text, masked.tokens)
  : null;
if (germanSubject === null)
  throw new Error("The translated subject lost or changed a placeholder.");
console.log(germanSubject); // {{first_name}}, Ihre Sable-Bestellung ist unterwegs

Pass the merge-tag syntax and doNotTranslate list your editor uses; the example uses SYNTAX_PRESETS.liquid. It prints {{first_name}}, Ihre Sable-Bestellung ist unterwegs. The field id is yours to choose; the provider returns it unchanged. Store each language's subject with the template in your own data.

Operations and collaboration ​

OperationEffect
addLocaleAdds a version to settings.locales
removeLocaleRemoves the version and every corresponding block and settings layer
setDefaultLocaleMakes a version the default; see Default language
updateBlockLocaleWrites a block's text, set, unset paths and hidden for a language
updateSettingsLocaleWrites the settings' text, set and unset for a language

Apply them with applyOperations and undo them with invertOperations from @templatical/types, run them through template-tools edit, or send them as a collaboration batch. Only these operations change localization fields: updateBlock and updateSettings refuse them, addBlock may carry a new block's l10n, and setContent replaces everything. A failed expect guard is precondition_failed; an invalid path, language or text field is invalid_operation. Operations gives the exact shapes, the order updates apply in and the undo guards.

BlockLock.locale?: string | null and OperationLock.locale?: string | null use null, or an absent value, for the default language and structure. A localized edit to (block X, de) conflicts with another session's lock on (X, de) or (X, null); a lock on (X, fr) can coexist. updateBlock, moveBlock and deleteBlock on X conflict with any other session's lock on X (a move or delete also with locks inside X), addBlock checks no locks, and setDefaultLocale conflicts with every lock. onRemoteChange.locale is the language of the batch's first locale operation, excluding setDefaultLocale, otherwise null; a setDefaultLocale batch reports null because it changes the template's default. See Collaboration for the exact contracts.

Compatibility ​

Existing single-language templates need no migration: they load, edit, save and render as before. Without localization there is no language UI, and existing layers survive default-language edits and saves. The editor writes localization fields only after an author adds a language.

All @templatical/* packages release together under one version number. Keep them on the same version: a renderer or validator from before language versions doesn't know l10n, so an older renderer renders the default language and an older validator rejects the template.

A collaboration backend you built yourself must apply the locale operations in the Operations reference and the lock locale; see Collaboration. Until it does, it rejects them as invalid_operation, and a backend that ignores the lock locale serializes edits per block. A comments backend you built yourself stores locale; see Comments.

Limits ​

  • Column structure is shared across languages and can't be overridden per language. Column order follows each language's direction, so a right-to-left version shows the first column on the right, but explicit left and right text alignment and left/right padding aren't mirrored: override them per language.
  • Text in a custom block's repeatable items can't be translated field by field, because the items have no ids. Override the whole fieldValues.<key> per language instead.
  • Translate leaves out HTML blocks. Translate them by hand in the language view.
  • Blocks the AI adds in a language view enter the shared tree with their text as default content, as a paste does. AI can't replace the whole template in a language view; switch to the default language for that.
  • Comment threads are shared across languages. Each new comment and reply stores its view’s language tag through your comments provider. Version history remains per template, not per language.
  • Strings are exchanged as JSON; XLIFF and CSV aren't supported.

Check your work ​

For a saved template.json, check and render one language:

bash
npx -y @templatical/template-tools validate template.json --locale de --json
npx -y @templatical/template-tools render template.json --locale de --format mjml --out template.de.mjml

validate --locale de validates every declared language's projection against the ordinary schemas, naming the language in projection errors, then lints the German projection, including localization.untranslated, localization.stale and localization.merge-tags-differ. Read the issues even on exit 0: missing and out-of-date translations are warnings. After strings import, read its rejected list before validating.

Exitvalidate and renderstrings import
0Success; warnings such as localization.untranslated and localization.stale can remainNo string was rejected (entries without text are skipped)
1Invalid structure, an unknown locale, or for validate an error-severity lint issue or localization.merge-tags-differ (even with valid: true)A string was rejected (the accepted strings are written), the template is invalid, or targetLocale isn't a version of it (nothing is written)
2Misuse or a tool failure: a missing or empty --locale value, an invalid --definitions file, or for validate an exception inside lintMisuse: missing arguments, or an unreadable or invalid strings, definitions or terms file
3A missing optional dependency: mjml for render --format html and validate --htmlNot used

An unknown locale exits 1 and writes unknown_locale to stderr without a result document, including with --json. A missing or empty --locale value exits 2. valid: true alone doesn't mean exit 0. See Template tools.

For custom-block translations, pass --definitions definitions.json, a JSON array of CustomBlockDefinition, to validate, render and both strings commands; rendering uses each type’s Liquid template and CSS. For the library route, use validateTemplate(content, definitions), then lintTemplate(content, { customBlocks: definitions, contentLocale: "de" }), and supply renderCustomBlock for rendering. This complete TypeScript example requires @templatical/types, @templatical/template-tools, @templatical/quality, @templatical/renderer and liquidjs:

ts
import {
  createCustomBlock,
  createDefaultTemplateContent,
  sourceHash,
  type CustomBlockDefinition,
} from "@templatical/types";
import { validateTemplate } from "@templatical/template-tools";
import { lintTemplate } from "@templatical/quality";
import { renderToMjml } from "@templatical/renderer";
import { Liquid } from "liquidjs";

const definitions: CustomBlockDefinition[] = [
  {
    type: "promo",
    name: "Promo",
    template: "<p>{{ headline }}</p>",
    fields: [{ key: "headline", type: "text", label: "Headline" }],
  },
];
const custom = createCustomBlock(definitions[0]);
custom.fieldValues.headline = "Welcome";
custom.renderedHtml = "<p>Welcome</p>";
custom.l10n = {
  de: {
    text: {
      "fieldValues.headline": {
        value: "Willkommen",
        sourceHash: sourceHash("Welcome"),
      },
    },
  },
};
const content = createDefaultTemplateContent("Arial", { locale: "en" });
content.settings.locales = ["de"];
content.blocks = [custom];

const validation = validateTemplate(content, definitions);
if (!validation.valid) throw new Error(validation.errors.join("\n"));
const issues = lintTemplate(content, {
  customBlocks: definitions,
  contentLocale: "de",
});
const localizationIssues = issues.filter((issue) =>
  issue.ruleId.startsWith("localization."),
);
if (localizationIssues.length > 0) {
  throw new Error(localizationIssues.map((issue) => issue.message).join("\n"));
}
const engine = new Liquid();
const mjml = await renderToMjml(content, {
  locale: "de",
  async renderCustomBlock(block) {
    const definition = definitions.find(
      (entry) => entry.type === block.customType,
    );
    if (!definition)
      throw new Error(`Unknown custom block: ${block.customType}`);
    return engine.parseAndRender(definition.template, block.fieldValues);
  },
});
console.log(mjml);

Check validation.valid, inspect issues, and confirm the MJML contains Willkommen rather than the cached Welcome. See Custom block rendering and the CLI library API. For a translation backend, run runTranslationConformance as described in the translation contract.

In the playground ​

The Languages setup opens one template with German text, a different hero image, a block hidden in German, an out-of-date German value, a layout footer per language, and a recorded translation provider with Sable on the do-not-translate list. Switch to Deutsch and use Translate {count} fields, or Re-translate on the out-of-date block; edit German text, then return to English to check that it is unchanged. Choose Keep to clear an out-of-date badge, add French, or select Arabic to see right-to-left content. Its variants show a changed tag (?translation=tags-changed), partial results on Cancel (?translation=cancel), collaboration locks (?collaboration=1&user=Ada) and 300 blocks in 20 languages (?scale=1).