Skip to content

Internationalization (i18n)

The framework supports internationalization built on top of the standard gettext library. Translations are organized by domains, letting you separate localization between application modules, and each request gets its language resolved automatically from the query string or the Accept-Language header.

Translations look up messages through a fallback chain: requested language → default language (en) → the message identifier itself. This means missing translations never crash a request — the original string is returned instead.

Configuration

Translations are configured in the optional translations section of config.json:

json
{
    "translations": [
        {
            "domain": "identity",
            "path": "backend/identity/locale"
        },
        {
            "domain": "shop",
            "path": "backend/shop/locale"
        }
    ]
}

Optional section

The translations section is optional. Omit it entirely if your application does not need localization — no translations map is created and the translation functions simply return the original strings.

domain string

Translation domain name. Used to reference a specific set of translations in code. Each entry must have a non-empty domain.

path string

Path to the locale directory (passed to gettext's bindtextdomain). Each entry must have a non-empty path.

Default language is hardcoded

The default (fallback) language is hardcoded to en and cannot be overridden per domain in config.json. Every domain uses en as its fallback regardless of configuration.

Translation file structure

Translation files must be located at the following path:

<path>/<lang>/LC_MESSAGES/<domain>.mo

For example, for domain identity and language ru:

backend/identity/locale/ru/LC_MESSAGES/identity.mo
backend/identity/locale/en/LC_MESSAGES/identity.mo

Only compiled .mo files are loaded — gettext does not read .po sources at runtime.

Creating translation files

1. Create PO file

Create an identity.po file for each language:

po
# English translations for identity module
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

msgid "Welcome"
msgstr "Welcome"

msgid "Invalid credentials"
msgstr "Invalid credentials"

# Plural forms
msgid "error"
msgid_plural "errors"
msgstr[0] "error"
msgstr[1] "errors"

Charset must be UTF-8

The framework binds every domain to the UTF-8 codeset, so the PO file's Content-Type must declare charset=UTF-8. Strings are then returned as UTF-8 regardless of the source encoding.

2. Compile to MO file

bash
msgfmt -o identity.mo identity.po

Reload after recompile

.mo files are loaded by gettext at startup / first use and may be cached by the C library. Restart the server after recompiling translations to make sure the new strings are picked up.

Usage in code

Include the header file:

c
#include "translation.h"

Simple translation

The tr function returns a translation by message identifier:

c
const char* message = tr(ctx, "identity", "Welcome");
// Result: "Welcome" (for en) or localized string (for other languages)

If no translation is found for the requested language, the function falls back to the default language (en); if still missing, it returns the msgid itself.

Warning

Do not free the memory returned by tr. The string is owned by the gettext runtime and may be reused across calls.

Translation with placeholders

The trf function replaces {key} placeholders with provided values:

c
// PO file: msgid "Hello, {name}!" msgstr "Hello, {name}!"

char* message = trf(ctx, "identity", "Hello, {name}!", "name", username, NULL);
// Result: "Hello, John!"

free(message); // Must free the memory

Argument list

Arguments are passed as "key", "value" pairs and terminated with NULL. Up to 32 key-value pairs are supported — extra pairs beyond this limit are ignored.

Plural forms

The trn function selects the correct form based on the count:

c
const char* message = trn(ctx, "identity", "error", "errors", error_count);
// 1 -> "error"
// 2 -> "errors"

If no translation is found for the requested language, the function falls back to the default language (en); if still missing, it returns singular when n == 1, otherwise plural.

Plural forms with placeholders

The trnf function combines plural forms and placeholder substitution:

c
char count_str[16];
snprintf(count_str, sizeof(count_str), "%d", count);

char* message = trnf(ctx, "identity", "{n} error found", "{n} errors found",
                     count, "n", count_str, NULL);
// 1 -> "1 error found"
// 3 -> "3 errors found"

free(message);

Language detection

Language is determined automatically per request in the following order of priority:

  1. Query parameter lang?lang=ru
  2. Accept-Language header — only the primary language code is extracted (e.g. ru-RU,ru;q=0.9,en-US;q=0.8ru)
  3. Default languageen

Supported locales

The detected language code is mapped to a system locale before gettext lookup. The following language codes are supported out of the box:

CodeLocale
enen_US.utf8
ruru_RU.utf8
dede_DE.utf8
frfr_FR.utf8
eses_ES.utf8
zhzh_CN.utf8
jaja_JP.utf8

If the requested language is not in this table, the framework falls back to the C.UTF-8 locale. Note that the corresponding <lang>/LC_MESSAGES/<domain>.mo file must still exist for the translation to be served.

Example

GET /api/users?lang=ru
Accept-Language: en-US,en;q=0.9

In this case, Russian (ru) will be used since the query parameter has the highest priority.

API reference

tr

c
const char* tr(httpctx_t* ctx, const char* domain, const char* msgid);
ParameterDescription
ctxHTTP context for language detection
domainTranslation domain
msgidMessage identifier
ReturnsTranslated string (do not free); falls back to default language, then to msgid

trf

c
char* trf(httpctx_t* ctx, const char* domain, const char* msgid, ...);
ParameterDescription
ctxHTTP context for language detection
domainTranslation domain
msgidMessage identifier with placeholders
..."key", "value" pairs terminated with NULL (max 32 pairs)
ReturnsTranslated string (caller must free), or NULL on allocation failure

trn

c
const char* trn(httpctx_t* ctx, const char* domain, const char* singular,
                const char* plural, unsigned long n);
ParameterDescription
ctxHTTP context for language detection
domainTranslation domain
singularSingular form
pluralPlural form
nCount for form selection
ReturnsTranslated string (do not free); falls back to default language, then to singular/plural

trnf

c
char* trnf(httpctx_t* ctx, const char* domain, const char* singular,
           const char* plural, unsigned long n, ...);
ParameterDescription
ctxHTTP context for language detection
domainTranslation domain
singularSingular form with placeholders
pluralPlural form with placeholders
nCount for form selection
..."key", "value" pairs terminated with NULL (max 32 pairs)
ReturnsTranslated string (caller must free), or NULL on allocation failure

Usage example

c
#include "http.h"
#include "translation.h"

void get_profile(httpctx_t* ctx) {
    // Simple translation
    const char* title = tr(ctx, "identity", "Profile");

    // Translation with placeholder
    char* greeting = trf(ctx, "identity", "Welcome back, {name}!",
                         "name", user->name, NULL);

    // Plural form
    char count_str[16];
    snprintf(count_str, sizeof(count_str), "%d", notification_count);
    char* notifications = trnf(ctx, "identity",
        "You have {n} new notification",
        "You have {n} new notifications",
        notification_count, "n", count_str, NULL);

    // ... build response ...

    free(greeting);
    free(notifications);
}

Released under the MIT License.