---
source: https://studio.eloqnt.dev/docs/formats/custom
docs_index: https://studio.eloqnt.dev/llms.txt
---

# Custom formats

Store messages in any file format by defining a codec that reads and writes your files.

The built-in formats are `po` and `json`. When your messages live in a different file format, a codec teaches the CLI how to read and write your files:

```ts title=".eloqnt/config.ts"
import {defineConfig} from '@eloqnt/cli';

export default defineConfig({
  messages: {
    format: {
      codec: './.eloqnt/StructuredJSONCodec.ts',
      extension: '.json'
    }
    // ...
  }
});
```

The codec can live at any path in your project. It can also come from an installed package like [`@eloqnt/format-apple-xcstrings`](https://studio.eloqnt.dev/docs/formats/apple-xcstrings), in which case you reference it by its package name.

## One file per locale

Most formats store one file per locale (e.g. `en.json`, `es.json`, etc.).

A codec for them implements `decode` and `encode`, created with `defineCodec`:

```ts title=".eloqnt/StructuredJSONCodec.ts"
import {defineCodec} from '@eloqnt/cli';

type Entry = {message: string; description?: Array<string>};

export default defineCodec(() => ({
  decode(content) {
    const data = JSON.parse(content) as Record<string, Entry>;
    return Object.entries(data).map(([id, entry]) => ({
      id,
      message: entry.message,
      description: entry.description ?? [],
      references: []
    }));
  },
  encode(messages) {
    const data: Record<string, Entry> = {};
    for (const message of messages) {
      data[message.id] = {
        message: message.message,
        description: message.description
      };
    }
    return JSON.stringify(data, null, 2);
  }
}));
```

This example reads a JSON format that stores message descriptions next to each value:

```json title="messages/en.json"
{
  "HomePage.title": {
    "message": "Welcome, {name}!",
    "description": ["Greeting for the user when the app starts."]
  }
}
```

A similar codec can be used to implement other formats, like:

- Java properties files
- Laravel JSON messages

## All locales in one file

Some formats keep every locale in a single shared file, like Apple's [`.xcstrings`](https://studio.eloqnt.dev/docs/formats/apple-xcstrings).

A codec declares this with `file: 'shared'` and implements `decodeAll` and `encodeAll` in place of `decode` and `encode`:

```ts title=".eloqnt/SharedJSONCodec.ts"
import {defineCodec} from '@eloqnt/cli';

type Messages = Record<string, string>;

export default defineCodec(() => {
  // Keeps the parsed file across decode and encode,
  // so unknown fields survive a write untouched
  let parsed: Record<string, Messages> = {};

  return {
    // This codec writes all locale-specific
    // translations to a single file
    file: 'shared',

    decodeAll(content) {
      parsed = JSON.parse(content) as Record<string, Messages>;
      return Object.fromEntries(
        Object.entries(parsed).map(([locale, messages]) => [
          locale,
          Object.entries(messages).map(([id, message]) => ({
            id,
            message,
            description: [],
            references: []
          }))
        ])
      );
    },

    encodeAll(messagesByLocale) {
      const data = {...parsed};
      for (const [locale, messages] of Object.entries(messagesByLocale)) {
        data[locale] = Object.fromEntries(
          messages.map((message) => [message.id, message.message])
        );
      }
      return JSON.stringify(data, null, 2);
    }
  };
});
```

This example reads a JSON file that nests messages under a locale key:

```json title="messages.json"
{
  "en": {"HomePage.title": "Welcome!"},
  "de": {"HomePage.title": "Willkommen!"}
}
```

With a shared file, [`messages.path`](https://studio.eloqnt.dev/docs/configuration#messages-path) points at the file itself and contains no `{locale}` placeholder, while the format continues to provide the extension:

```ts title=".eloqnt/config.ts"
import {defineConfig} from '@eloqnt/cli';

export default defineConfig({
  messages: {
    path: './messages',
    locales: 'infer',
    sourceLocale: 'en',
    format: {
      codec: './.eloqnt/SharedJSONCodec.ts',
      extension: '.json'
    }
  }
});
```

A few things to know about shared-file codecs:

- The CLI creates one codec instance per file and calls `decodeAll` before `encodeAll` whenever the file exists on disk. State you keep in the closure (like `parsed` above) is how other content in the file survives a write.
- When the file doesn't exist yet, `encodeAll` runs without a prior `decodeAll` and renders the file from scratch.
- With `locales: 'infer'`, the locales are read from inside the file.

## Converting structured values

Some formats store plurals as structured data rather than as part of the message text:

```json title="messages/en.json"
{
  "itemsCount": {
    "one": "One item",
    "other": "# items"
  }
}
```

In that case, convert the structure to an ICU plural on `decode`:

```text
{itemsCount, plural, one {One item} other {# items}}
```

… and transform it back to the structured form on `encode`.

This enables checks like lint rules to function correctly on the message.

## Preconfigured formats

The following formats are preconfigured, and ready to use:

1. [Apple .xcstrings](https://studio.eloqnt.dev/docs/formats/apple-xcstrings)
2. [Android XML](https://studio.eloqnt.dev/docs/formats/android-xml)

---

For an index of every eloqnt/studio documentation page, see [https://studio.eloqnt.dev/llms.txt](https://studio.eloqnt.dev/llms.txt).
