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:
{
"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>.moFor example, for domain identity and language ru:
backend/identity/locale/ru/LC_MESSAGES/identity.mo
backend/identity/locale/en/LC_MESSAGES/identity.moOnly 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:
# 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
msgfmt -o identity.mo identity.poReload 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:
#include "translation.h"Simple translation
The tr function returns a translation by message identifier:
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:
// 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 memoryArgument 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:
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:
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:
- Query parameter
lang—?lang=ru Accept-Languageheader — only the primary language code is extracted (e.g.ru-RU,ru;q=0.9,en-US;q=0.8→ru)- Default language —
en
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:
| Code | Locale |
|---|---|
en | en_US.utf8 |
ru | ru_RU.utf8 |
de | de_DE.utf8 |
fr | fr_FR.utf8 |
es | es_ES.utf8 |
zh | zh_CN.utf8 |
ja | ja_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.9In this case, Russian (ru) will be used since the query parameter has the highest priority.
API reference
tr
const char* tr(httpctx_t* ctx, const char* domain, const char* msgid);| Parameter | Description |
|---|---|
| ctx | HTTP context for language detection |
| domain | Translation domain |
| msgid | Message identifier |
| Returns | Translated string (do not free); falls back to default language, then to msgid |
trf
char* trf(httpctx_t* ctx, const char* domain, const char* msgid, ...);| Parameter | Description |
|---|---|
| ctx | HTTP context for language detection |
| domain | Translation domain |
| msgid | Message identifier with placeholders |
| ... | "key", "value" pairs terminated with NULL (max 32 pairs) |
| Returns | Translated string (caller must free), or NULL on allocation failure |
trn
const char* trn(httpctx_t* ctx, const char* domain, const char* singular,
const char* plural, unsigned long n);| Parameter | Description |
|---|---|
| ctx | HTTP context for language detection |
| domain | Translation domain |
| singular | Singular form |
| plural | Plural form |
| n | Count for form selection |
| Returns | Translated string (do not free); falls back to default language, then to singular/plural |
trnf
char* trnf(httpctx_t* ctx, const char* domain, const char* singular,
const char* plural, unsigned long n, ...);| Parameter | Description |
|---|---|
| ctx | HTTP context for language detection |
| domain | Translation domain |
| singular | Singular form with placeholders |
| plural | Plural form with placeholders |
| n | Count for form selection |
| ... | "key", "value" pairs terminated with NULL (max 32 pairs) |
| Returns | Translated string (caller must free), or NULL on allocation failure |
Usage example
#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);
}