Skip to main content

File format

better ds pull writes a design system as a small, fixed tree of files. better ds push reads the same tree back. The mapping is exact in both directions: pulling a design system you have not touched and pushing it again produces no change.

For a complete design system in this format, read betteremail/meridian — a brand-neutral starter with twelve modules, MIT licensed. The snippets below are deliberately small; Meridian is where to look for how a real one fits together.

design-system.json content zones, metadata, assets, values
base.liquid the template base
settings.json design-system-level settings (Global, Shared, and your own)
modules.order module order, one directory name per line
modules/
hero/
module.json module metadata
module.liquid the module's Liquid
settings.json the module's settings
feature_row/
module.json
module.liquid
settings.json
.better/config.json which design system this checkout is bound to

Only these paths are managed. Anything else in the directory — a README.md, a .github/ folder, a scratch file inside a module directory — is preserved by pull, which lists what it left alone, and never read by check, dev, or push. Unmanaged files are not validated and never reach the platform.

The JSON files are written with sorted keys and two-space indentation, so a git diff after an edit shows only what you changed.

design-system.json

Everything about the design system that is not Liquid, settings, or a module.

{
"$schema": "https://app.better.email/schemas/design-system.schema.json",
"assets": [
{
"id": "asset_logo",
"name": "Acme logo",
"type": "image/png",
"url": "https://assets.better.email/acme/logo.png"
}
],
"contentZones": [
{
"id": "content",
"isDefault": true,
"name": "Main",
"variable": "content"
},
{
"id": "footer",
"name": "Footer",
"variable": "content_footer"
}
],
"metadata": {
"aiMetadata": "Acme's transactional and marketing newsletter."
},
"values": {},
"version": 3
}

All five properties are required, and no others are accepted:

PropertyWhat it holds
versionAlways 3. The definition format version.
contentZonesThe content zones the template base renders.
metadataDesign-system metadata, including aiMetadata for Betty and the thumbnail.
assetsFiles uploaded to the design system, each with id, name, url, and type.
valuesArbitrary JSON owned by the design system.

The first content zone is always the canonical Main zone — id: "content", variable: "content", isDefault: true. Additional zones need an id, a name, and a variable that starts with content_ and ends in a letter or digit.

Assets are uploaded in the Design System Editor. The file records the resulting URLs so your Liquid can reference them; it does not upload anything.

base.liquid

The template base, exactly as you would write it in the editor. It must render {{ content }}, plus one variable for every additional content zone.

<!doctype html>
<html>
<body style="margin:0;background:{{ template.page.background_color }}">
<div style="display:none">{{ template.preheader.text }}</div>
<table role="presentation" width="600" align="center">
{{ content }}
{{ content_footer }}
</table>
</body>
</html>

Design-system-level settings are addressed with a template. prefix here, brand values from the Global setting as global.<input_key>. See addressing values below.

Both base.liquid and every module.liquid must end with a trailing newline. That newline is a sentinel the CLI strips on read, so a file without one is rejected rather than silently trimmed.

settings.json

The design-system-level settings. The file is an object with a $schema key and a settings array — nothing else.

{
"$schema": "https://app.better.email/schemas/template-settings.schema.json",
"settings": [
{
"id": "__template_global_setting__",
"inputs": [
{
"defaultValue": "#0b5cff",
"id": "input_brand_accent",
"key": "color_accent",
"name": "Accent colour",
"type": "color"
}
],
"key": "global",
"name": "Global",
"repeatable": false,
"specialType": "global",
"transformLiquid": "",
"useFeed": false
},
{
"id": "__template_shared_setting__",
"inputs": [
{
"defaultValue": "left",
"id": "input_shared_alignment",
"key": "alignment",
"name": "Alignment",
"options": [
{ "id": "option_left", "name": "Left", "value": "left" },
{ "id": "option_center", "name": "Centre", "value": "center" }
],
"type": "select"
}
],
"key": "shared",
"name": "Shared",
"repeatable": false,
"specialType": "shared",
"useFeed": false
},
{
"id": "setting_preheader",
"inputs": [
{
"constraints": [{ "type": "maxChar", "value": 90 }],
"defaultValue": "",
"id": "input_preheader_text",
"key": "text",
"name": "Preview text",
"type": "string"
}
],
"key": "preheader",
"name": "Preheader",
"repeatable": false,
"useFeed": false
}
]
}

Global and Shared come first

The first two entries are always the pinned Global and Shared settings, in that order, with fixed ids, keys, names, and specialType values. Their inputs arrays are yours to edit; their identity is not. No other setting may claim the global or shared key, id, or specialType.

The Global setting carries the Global Script in its transformLiquid property, as a Liquid string. It is present even when empty.

Add your own settings after those two.

Module settings

modules/<key>/settings.json has the same shape, against settings.schema.json:

{
"$schema": "https://app.better.email/schemas/settings.schema.json",
"settings": [
{
"id": "setting_hero",
"inputs": [
{
"constraints": [{ "type": "mandatory" }, { "type": "maxChar", "value": 60 }],
"defaultValue": "Something worth opening",
"id": "input_hero_headline",
"key": "headline",
"name": "Headline",
"type": "string"
},
{
"defaultValue": { "rendered": "<p>One short paragraph.</p>", "type": "rich" },
"id": "input_hero_body",
"key": "body",
"name": "Body",
"type": "text"
},
{
"id": "input_hero_alignment",
"key": "alignment",
"name": "Alignment",
"sharedInputId": "input_shared_alignment",
"type": "shared"
},
{
"defaultValue": true,
"id": "input_hero_show_button",
"key": "show_button",
"name": "Show button",
"type": "boolean"
},
{
"defaultValue": "Shop now",
"id": "input_hero_cta_label",
"key": "cta_label",
"name": "Button label",
"only_show_if": "hero.show_button",
"type": "string"
},
{
"defaultValue": { "url": "https://example.com" },
"id": "input_hero_cta",
"key": "cta",
"name": "Button link",
"only_show_if": "hero.show_button",
"requireHttps": true,
"type": "link"
}
],
"key": "hero",
"name": "Hero",
"repeatable": false,
"useFeed": false
}
]
}

A setting needs id, key, name, inputs, useFeed, and repeatable. An input needs id, key, name, and type. Everything else — constraints, only_show_if, placeholder, the per-type options documented under input types — is optional and matches what the Design System Editor writes.

A Shared Reference is an input of type shared whose sharedInputId points at an input id inside the Shared setting.

Repeatable settings carry repeatable: true plus itemName, defaultInstancesCount, minItems, and maxItems. See repeatable settings.

Setting keys are globally unique

A setting key must be unique across the whole design system — the design-system-level settings and every module together. Two modules that both expose a setting keyed hero is an error, not a namespace collision. better check reports it with both file paths.

modules/

One directory per module, each holding exactly three files.

module.json is the module's metadata — everything except its Liquid and its settings:

{
"$schema": "https://app.better.email/schemas/module.schema.json",
"hidden": false,
"id": "cmp_hero",
"key": "hero",
"metadata": {
"allowedContentZoneIds": ["content"]
},
"name": "Hero"
}

id, key, name, hidden, and metadata are required. only_show_if holds the module's conditional-visibility expression. metadata.allowedContentZoneIds restricts where the module can be dropped; metadata.aiMetadata and metadata.thumbnail carry the module's Betty context and thumbnail.

content and settings are rejected in module.json — they live in module.liquid and settings.json beside it.

module.liquid is the module's markup, rendered inside the template base:

{% comment %}
Hero — the button is optional, so both of its inputs hide together
behind one toggle.
{% endcomment %}
<tr>
<td align="{{ hero.alignment }}" style="padding:24px">
<h1>{{ hero.headline }}</h1>
{{ hero.body }}
{%- if hero.show_button %}
<a href="{{ hero.cta.url }}" {{ hero.cta | linkAttributes }} style="background:{{ global.color_accent }}">{{ hero.cta_label }}</a>
{%- endif %}
</td>
</tr>

Whitespace control at the module boundary

The first and last Liquid tags must not trim whitespace

The renderer splices each module into the email between HTML comment markers and pulls the rendered block back out by matching <!-- BLOCK-id -->\n…\n<!-- BLOCK-id -->. Those two newlines are load-bearing.

A module whose first tag left-trims ({%- …, {{- …) eats the newline after the opening marker. A module whose last tag right-trims (… -%}, … -}}) eats the newline before the closing marker. Either way the match fails and the module renders as nothing at all — no error, no warning, just an email with a gap where the module should be.

Open every module with a plain {% comment %}, never {%- comment -%}, and end it on markup rather than a trimming tag. Inside the module, trim as much as you like: only the two boundaries matter. Every module in Meridian follows this pattern.

Identity is the id, not the directory

The module's identity is the id in module.json. Campaigns, diffs, and usage checks all address that value. Directory names exist to make the tree readable: renaming modules/hero/ to modules/masthead/ (and updating modules.order) changes nothing on the platform as long as the id stays the same. Changing the id deletes one module and adds another.

Two modules with the same id is an error, reported with both module.json paths.

The key is the module's canonical key: a lowercase letter, then letters and digits in segments separated by single underscores — hero, feature_row, product_row_2. Pull derives each directory name from it, so a directory you renamed without renaming the key is renamed back on the next pull.

The id template is reserved for the design system's own preview and cannot be used by a module.

Addressing values in Liquid

Three namespaces, and which prefix you need depends on where the setting lives, not on where you are writing.

WhatHow to address itWhere
A module's own settings{{ setting_key.input_key }}that module's module.liquid
Design-system-level settings{{ template.setting_key.input_key }}base.liquid and every module
The Global setting's inputs and computed values{{ global.input_key }}base.liquid and every module

The Global setting is the exception that catches people out: it is a design-system-level setting, but it is lifted out of template and given its own root namespace. {{ template.global.color_accent }} resolves to nothing — write {{ global.color_accent }}. Everything the Global Script exports with set_global appears in the same namespace.

Module settings are unprefixed because a module only ever sees its own. That is also why setting keys are unique design-system-wide: they share one flat namespace at render time. The names template, global, and meta are taken by the render context, so a module setting keyed one of those is unreachable.

A Shared Reference is addressed by its local key, not the key of the input it points at: the alignment reference on the hero setting above is {{ hero.alignment }}.

Repeatable settings

A repeatable setting's key holds a plain array. Iterate it directly; the loop variable carries the setting's input keys, and size gives the count.

{% comment %}
Feature row — one column per repeatable item, sharing the 560px
container evenly.
{% endcomment %}
{%- assign feature_count = features | size -%}
{%- assign column_width = 560 | divided_by: feature_count -%}
<tr>
<td style="padding:0 24px;font-size:0">
{%- for feature in features %}
<div style="display:inline-block;width:{{ column_width }}px">
<img src="{{ feature.image.url }}" alt="{{ feature.image.altText }}" width="{{ column_width }}" />
<h2>{{ feature.title }}</h2>
</div>
{%- endfor %}
</td>
</tr>

Value shapes

Most inputs arrive as the scalar you expect. Three do not:

  • Image values are objects: {{ image_input.url }} for the source and {{ image_input.altText }} for the alternative text. Outputting the input itself prints nothing useful.
  • Link values are objects too: {{ link_input.url }} for the destination. {{ link_input | linkAttributes }} expands any custom attributes the input collected into name="value" pairs, ready to drop inside an <a> tag, and returns an empty string when there are none.
  • Rich text arrives already rendered as HTML. Output it directly — {{ hero.body }} — with no filter. There is no auto-escaping to defeat, and no .rendered property to reach for at render time, even though that is how the default value is stored in settings.json.

Conditional visibility

only_show_if on a setting or an input is evaluated against the module's own values, so it can reference a sibling input by its full setting_key.input_key path. That is how the hero above hides its label and its link behind one toggle:

"only_show_if": "hero.show_button"

only_show_if on the module itself — the only_show_if in module.json — is evaluated with the module's own settings deliberately out of scope, because the decision is made before any block exists. It sees global.*, template.*, and context.integration.id, and nothing else — not context.isPreview, not context.email, and not the module's own settings. A condition that reaches for one of those is not a syntax error: it resolves to nothing, evaluates as false, and the module quietly disappears from the picker.

modules.order

The module order shown in the Campaign Editor's module picker: one directory name per line, LF endings, trailing newline.

hero
feature_row

Every module directory must appear exactly once, and every line must name a directory that exists. better check reports either mismatch by name and line number. Reordering modules means reordering this file — nothing else moves.

.better/config.json

Written by better ds pull and updated by better ds push. It records which design system the checkout is bound to and which version it came from:

{
"designSystemId": "k97a1c8y3fq2m5r0n6w4hz7b",
"designSystemName": "Acme Newsletter",
"channel": "live",
"pulledRevisionId": "k1739fdz2xy8p4c0v6s3jr5q",
"pulledSha256": "b0f3…",
"latestRevisionIdAtPull": "k1739fdz2xy8p4c0v6s3jr5q"
}

The binding is what lets you run better ds diff, better ds status, and better ds push without repeating the design system id. pulledSha256 is how status tells clean from modified, and latestRevisionIdAtPull is the optimistic-concurrency base for push: if the design system has moved on since you pulled, push refuses rather than layering your tree on top of someone else's work.

npm create better-email adds .better/ to .gitignore, because the binding is per-checkout state rather than shared source. Pushing from CI is the one case where you commit it.

Editor autocomplete

Every JSON file carries a $schema key pointing at a published JSON Schema:

FileSchema
design-system.jsonhttps://app.better.email/schemas/design-system.schema.json
settings.json (design-system level)https://app.better.email/schemas/template-settings.schema.json
modules/<key>/settings.jsonhttps://app.better.email/schemas/settings.schema.json
modules/<key>/module.jsonhttps://app.better.email/schemas/module.schema.json

Any editor with JSON Schema support — VS Code, JetBrains IDEs, Neovim with a language server — reads that key and gives you completion for property names and enum values, hover documentation, and inline validation as you type. Nothing to configure.

The Better Email Design Systems extension for VS Code bundles the same schemas and binds them to design-system.json, modules/*/module.json, and modules/*/settings.json by filename, so those three work without fetching anything. The design-system-level settings.json uses its $schema declaration.

The extension also covers the half of the format JSON Schema cannot describe. In base.liquid and module.liquid it completes the values the file can actually address — the module's own settings unprefixed, template.*, global.*, context.* — follows for and tablerow loop variables into repeatable settings, and hovers an input reference to show its name, type, and default value. See editor support.

Forked schema URLs are allowed. If you host a copy, point $schema at it — the CLI only requires the value to be a string, and strips the key before sending anything to the platform.

What the CLI will not do quietly

better check and better ds push both validate the tree, and both refuse rather than repair:

  • No silent normalization. The files are already in canonical form. If a value you wrote would be rewritten on import — a typo in allowedContentZoneIds, a content-zone name that would be trimmed — the CLI names the file, the path inside it, your value, and the canonical one, instead of accepting the edit and changing its meaning.
  • No missing files. All four top-level files must exist, and each module directory needs all three of its files.
  • No unsafe numbers. Numbers must stay inside the double-precision safe-integer range, so a value cannot change while round-tripping through JSON.
  • No symlinks. The CLI reads and writes regular files only.

Local checks are not the last word. better ds push validates the tree locally, then the platform validates the design system again server-side — content-zone coherence, module usage, and the 5 MB definition limit — and reports what failed.