> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Locales

> Internationalising theme copy and schema labels

# Locales

Every user-facing string in a LaunchMyStore theme — button labels, error
messages, ARIA copy, schema names shown in the editor — lives in the
`locales/` directory and is translatable across any number of languages.

This page covers:

1. The file layout under `locales/`.
2. The `{{ 'key' | t }}` filter for translating storefront copy.
3. The `t:` prefix for translating schema labels.
4. How translations are resolved at install time and at render time.
5. How the active locale is selected per request.
6. Translation for navigation menus (`linklists`).

***

## File layout

A theme's `locales/` directory holds two parallel families of files:

```
locales/
├── en.default.json           ← storefront copy, fallback locale
├── en.default.schema.json    ← schema labels, fallback locale
├── fr.json                   ← storefront copy, French
├── fr.schema.json            ← schema labels, French
├── de.json
├── de.schema.json
├── es.json
└── es.schema.json
```

Two suffix patterns matter:

| Suffix               | Content                                                                                                                      |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `<lang>.json`        | Storefront copy — every string emitted via `{{ 'key' \| t }}`.                                                               |
| `<lang>.schema.json` | Schema labels — every `"label"`, `"info"`, `"name"`, and `"default"` referenced via `"t:..."` keys in section/block schemas. |

Two filename markers matter:

| Marker          | Meaning                                                                                                                                                          |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.default`      | Exactly one locale per theme is marked default (typically `en.default.json` + `en.default.schema.json`). Missing keys in other locales fall back to the default. |
| (no `.default`) | Regular locale file. Used when the request's active locale matches.                                                                                              |

A theme **must** have an `en.default.json` (or equivalent default-marked
file). It **should** have an `en.default.schema.json` for schema labels.
Other locales are optional and inherited from the default whenever a key is
missing.

***

## Translating storefront copy: the `t` filter

In templates, snippets, sections, and blocks, translate a string with the
`t` filter:

```aqua theme={null}
<button class="btn">{{ 'general.cart.add_to_cart' | t }}</button>

<input
  type="search"
  placeholder="{{ 'general.search.placeholder' | t }}"
  aria-label="{{ 'accessibility.search_input' | t }}"
>

<p>{{ 'products.product.sold_out' | t }}</p>
```

The key is a **dot-delimited path** into the locale JSON. Given:

```json theme={null}
// locales/en.default.json
{
  "general": {
    "cart": {
      "add_to_cart": "Add to cart",
      "checkout":   "Check out"
    },
    "search": {
      "placeholder": "Search the store"
    }
  },
  "accessibility": {
    "search_input": "Search input"
  },
  "products": {
    "product": {
      "sold_out": "Sold out"
    }
  }
}
```

…the templates above render `Add to cart`, `Search the store`, `Search
input`, and `Sold out` respectively.

### Interpolation

A translation value can include `{{ name }}` placeholders that the `t`
filter fills in from keyword arguments:

```json theme={null}
{
  "products": {
    "product": {
      "save_amount": "Save {{ amount }}",
      "review_count": "{{ count }} reviews"
    }
  }
}
```

```aqua theme={null}
{{ 'products.product.save_amount' | t: amount: discount }}
{{ 'products.product.review_count' | t: count: product.reviews_count }}
```

### Pluralisation

Provide an object with `one` / `other` (CLDR-compliant) keys; pass the
`count` argument and the filter selects the right form:

```json theme={null}
{
  "accessibility": {
    "filter_count": {
      "one":   "{{ count }} filter applied",
      "other": "{{ count }} filters applied"
    }
  }
}
```

```aqua theme={null}
{{ 'accessibility.filter_count' | t: count: filters.size }}
```

`count: 1` → `1 filter applied`. `count: 5` → `5 filters applied`. Other
CLDR forms (`zero`, `two`, `few`, `many`) are supported for languages that
need them.

### Fallback behavior

If the key doesn't exist in the active locale, the renderer:

1. Looks in `<lang>.json`.
2. Falls back to `<default>.default.json`.
3. Renders the key string itself (`general.cart.add_to_cart`) if still
   nothing found — visible but not crashed.

***

## Translating schema labels: the `t:` prefix

Section and block schemas use a different syntax: any string value in the
schema can be replaced with `"t:..."` and the platform resolves it to the
corresponding translation.

```aqua theme={null}
{# sections/featured-collection.aqua #}
{% schema %}
{
  "name": "t:names.featured_collection",
  "settings": [
    {
      "type": "text",
      "id": "heading",
      "label": "t:settings.heading.label",
      "info":  "t:settings.heading.info",
      "default": "t:settings.heading.default"
    },
    {
      "type": "range",
      "id": "products_count",
      "label": "t:settings.products_count.label",
      "min": 2, "max": 12, "step": 1,
      "default": 4
    }
  ],
  "presets": [
    {
      "name": "t:presets.featured_collection.name",
      "settings": { "products_count": 4 }
    }
  ]
}
{% endschema %}
```

The matching schema-locale file:

```json theme={null}
// locales/en.default.schema.json
{
  "names": {
    "featured_collection": "Featured collection"
  },
  "settings": {
    "heading": {
      "label":   "Heading",
      "info":    "Displayed above the product grid.",
      "default": "Featured products"
    },
    "products_count": {
      "label": "Products to show"
    }
  },
  "presets": {
    "featured_collection": {
      "name": "Featured collection"
    }
  }
}
```

A `"t:..."` value can appear **anywhere** in the schema: `name`, `label`,
`info`, `default`, `options[].label`, `blocks[].name`, `presets[].name`,
`paragraph` content, `header` content. The resolver walks the whole schema
recursively and replaces every `t:`-prefixed string.

### Resolution order: schema vs storefront

When resolving a `"t:..."` key, the renderer first looks in
`<lang>.schema.json`, then falls back to `<lang>.json`. This lets you keep
editor-only strings (section names, preset descriptions) separate from
storefront copy without duplicating them.

```json theme={null}
// locales/en.default.schema.json — editor-only
{
  "names": { "featured_collection": "Featured collection" }
}
```

```json theme={null}
// locales/en.default.json — storefront strings
{
  "products": { "product": { "sold_out": "Sold out" } }
}
```

A `"t:names.featured_collection"` schema label resolves out of the schema
file; a `{{ 'products.product.sold_out' | t }}` template call resolves out
of the storefront file.

***

## Resolution lifecycle

Schema `t:` keys are resolved in two places:

### 1. At install time

When the theme is uploaded, the installer parses every section and block
schema, runs `expandSchemaTranslations()` over each one, and writes the
resolved values into `config/schema-index.json`. This is the index the
theme editor reads to populate its UI — the merchant sees real labels, not
`t:` keys.

The installer also flattens the active default locale into the index so
later lookups don't have to re-parse the locale JSON.

For app extensions that ship their own blocks (storefront app blocks),
the platform performs the same expansion against the app's bundled
`locales/<lang>.json` file.

### 2. At render time

During a page render, the renderer reads `locales/<lang>.schema.json` and
`locales/<lang>.json`, merges them, and caches the merged result per
theme (the cache is cleared when the theme is republished). Any `t:` key
still present in a schema is resolved against this merged map.

The merge is `{ ...mainTranslations, ...schemaTranslations }` —
schema-specific keys take precedence over storefront copy if there's a
namespace collision.

<Note>
  A `t:` key that can't be resolved at install **or** render time will be
  displayed in the editor as the literal key string. That's the symptom to
  watch for: if you see `t:settings.heading.label` in the theme editor's
  sidebar, it means a key is missing from your `.schema.json` files.
</Note>

***

## Locale detection

The renderer picks the active locale for a request from the following
sources, in order. The first match wins.

| Source                   | Notes                                                       |
| ------------------------ | ----------------------------------------------------------- |
| `?locale=<code>` query   | Used by the theme editor and section-AJAX fetches.          |
| `locale_code` cookie     | Set on first visit by middleware based on auto-detection.   |
| `Accept-Language` header | Falls back to the primary language tag (e.g. `fr-CA → fr`). |
| Theme default            | The locale whose filename ends in `.default.json`.          |

### Auto-detection on first visit

For a brand-new visitor, middleware reads the `cf-ipcountry` header
(populated by Cloudflare's edge), maps it to a default language, and
combines it with the user's `Accept-Language` header. The result is
written to three cookies:

| Cookie          | Holds                                              |
| --------------- | -------------------------------------------------- |
| `country_code`  | Two-letter ISO country code (e.g. `"US"`, `"AE"`). |
| `currency_code` | ISO-4217 currency code (e.g. `"USD"`, `"AED"`).    |
| `locale_code`   | Two-letter language code (e.g. `"en"`, `"ar"`).    |

Subsequent requests use the cookies directly — middleware doesn't
re-detect.

### Exposing the locale in templates

The active locale is exposed via `request.locale`:

```aqua theme={null}
<html lang="{{ request.locale.iso_code }}">
```

For form submissions that need a hidden `locale` field, the
`form_locale` filter or a direct render of `request.locale.iso_code` will
both work.

### Available locales

A theme's available locales are inferred from the filenames present in
`locales/`. Use them to render a language switcher:

```aqua theme={null}
<form method="post" action="/localization">
  <select name="locale_code" onchange="this.form.submit()">
    {% for locale in localization.available_languages %}
      <option
        value="{{ locale.iso_code }}"
        {% if locale.iso_code == request.locale.iso_code %}selected{% endif %}
      >
        {{ locale.endonym_name }}
      </option>
    {% endfor %}
  </select>
</form>
```

The `localization` global is populated from the merchant's enabled markets
and the locales the theme ships. See
[`localization`](/aqua/objects#localization) for the full shape.

***

## Navigation menu translations

Storefront navigation menus (`linklists.main-menu`, `linklists.footer-menu`,
etc.) are owned by the merchant — they're not part of the theme bundle.
Translations are managed in the **admin Navigation editor**, not in
`locales/` files.

In templates, render a linklist as you normally would:

```aqua theme={null}
<nav>
  {% for link in linklists.main-menu.links %}
    <a href="{{ link.url }}" {% if link.active %}aria-current="page"{% endif %}>
      {{ link.title }}
    </a>
  {% endfor %}
</nav>
```

`link.title` and `link.url` are returned in the request's active locale —
the backend resolves translations server-side based on `request.locale`
before handing the linklist to the renderer. Theme code never has to call
`| t` on a linklist's title.

If you ship a fallback menu that the merchant might not have configured,
guard with `default_menu`:

```aqua theme={null}
{% assign menu = linklists.main-menu | default: linklists.default-menu %}
{% for link in menu.links %}
  <a href="{{ link.url }}">{{ link.title }}</a>
{% endfor %}
```

***

## Worked example: a full set of locale files

A small theme that ships English (default) and French.

### `locales/en.default.json`

```json theme={null}
{
  "general": {
    "cart": {
      "add_to_cart": "Add to cart",
      "checkout": "Check out",
      "empty": "Your cart is empty"
    },
    "search": {
      "placeholder": "Search…",
      "no_results": "No results for {{ terms }}"
    },
    "404": {
      "title": "Page not found",
      "subtitle": "Try searching for what you're looking for."
    }
  },
  "products": {
    "product": {
      "sold_out": "Sold out",
      "save_amount": "Save {{ amount }}",
      "review_count": {
        "one": "{{ count }} review",
        "other": "{{ count }} reviews"
      }
    }
  },
  "accessibility": {
    "skip_to_text": "Skip to content",
    "cart_count": "Total items in cart",
    "menu": "Menu",
    "search_input": "Search input"
  }
}
```

### `locales/en.default.schema.json`

```json theme={null}
{
  "names": {
    "featured_collection": "Featured collection",
    "rich_text": "Rich text",
    "image_banner": "Image banner"
  },
  "categories": {
    "basic": "Basic",
    "products": "Products",
    "storytelling": "Storytelling"
  },
  "settings": {
    "heading": {
      "label": "Heading",
      "default": "Featured products"
    },
    "products_count": {
      "label": "Products to show"
    },
    "columns": {
      "label": "Columns"
    }
  },
  "presets": {
    "featured_collection": {
      "name": "Featured collection"
    }
  }
}
```

### `locales/fr.json`

```json theme={null}
{
  "general": {
    "cart": {
      "add_to_cart": "Ajouter au panier",
      "checkout": "Passer à la caisse",
      "empty": "Votre panier est vide"
    },
    "search": {
      "placeholder": "Rechercher…",
      "no_results": "Aucun résultat pour {{ terms }}"
    },
    "404": {
      "title": "Page introuvable",
      "subtitle": "Essayez de rechercher ce que vous cherchez."
    }
  },
  "products": {
    "product": {
      "sold_out": "Épuisé",
      "save_amount": "Économisez {{ amount }}",
      "review_count": {
        "one": "{{ count }} avis",
        "other": "{{ count }} avis"
      }
    }
  },
  "accessibility": {
    "skip_to_text": "Aller au contenu",
    "cart_count": "Total des articles dans le panier",
    "menu": "Menu",
    "search_input": "Champ de recherche"
  }
}
```

### `locales/fr.schema.json`

```json theme={null}
{
  "names": {
    "featured_collection": "Collection en vedette",
    "rich_text": "Texte enrichi",
    "image_banner": "Bannière image"
  },
  "categories": {
    "basic": "Basique",
    "products": "Produits",
    "storytelling": "Narration"
  },
  "settings": {
    "heading": {
      "label": "Titre",
      "default": "Produits en vedette"
    },
    "products_count": {
      "label": "Produits à afficher"
    },
    "columns": {
      "label": "Colonnes"
    }
  },
  "presets": {
    "featured_collection": {
      "name": "Collection en vedette"
    }
  }
}
```

When the active locale is `fr`, both storefront templates and editor
labels render in French. Any key missing from `fr.json` or
`fr.schema.json` falls back to `en.default.json` / `en.default.schema.json`
respectively.

***

## Tips and gotchas

* **One default per theme**: only one file may end in `.default.json`.
  Marking two locales as default is undefined behaviour and the installer
  may pick either.
* **JSON, with comments**: locale files are parsed by the platform's safe
  JSON parser, which accepts `//`-style comments. Use them generously —
  they're stripped at parse time and don't ship to the storefront.
* **Don't translate IDs**: `id`, `type`, and `value` fields in schemas are
  identifiers, not labels. The `t:` prefix is only meaningful on display
  strings.
* **Refresh after editing**: when you change a `.schema.json` file,
  republish the theme to rebuild the merged schema-translations cache.
  The editor pulls from the baked `schema-index.json`, which is
  regenerated on install.
* **App-block translations**: third-party app blocks ship their own
  `locales/<lang>.json` inside the app extension. Their `t:` keys are
  resolved against that file, not the theme's locales.

***

## Next steps

<CardGroup cols={2}>
  <Card title="Theme structure" icon="folder-tree" href="/aqua/theme-structure">
    Where `locales/` fits in the overall theme directory.
  </Card>

  <Card title="Filters" icon="filter" href="/aqua/filters">
    The `t`, `format_address`, and money filters for I18n.
  </Card>

  <Card title="Schema" icon="code" href="/aqua/schema">
    Where `t:` keys can appear in a section schema.
  </Card>

  <Card title="Objects" icon="cube" href="/aqua/objects">
    The `request`, `localization`, and `linklists` globals.
  </Card>
</CardGroup>
