Skip to main content

Internationalization (i18n)

This plugin allows you translate your AdminForth application to multiple languages. Main features:

  • Stores all translation strings in your application in a single AdminForth resource. You can set allowed actions only to Developers/Translators role if you don't want other users to see/edit the translations.
  • Supports AI completion adapters to help with translations. For example, you can use OpenAI ChatGPT to generate translations. Supports correct pluralization, even for Slavic languages.
  • Supports any number of languages.

Under the hood it uses vue-i18n library and provides several additional facilities to make the translation process easier.

Installationโ€‹

To install the plugin:

npm install @adminforth/i18n --save
npm install @adminforth/completion-adapter-open-ai-chat-gpt --save

For example lets add translations to next 4 languages: Ukrainian, Japanese, French, Spanish. Also we will support basic translation for English.

Add a model for translations, if you are using prisma, add something like this:

./schema.prisma
model translations {
id String @id
en_string String
created_at DateTime
uk_string String? // translation for Ukrainian language
ja_string String? // translation for Japanese language
fr_string String? // translation for French language
es_string String? // translation for Spanish language
category String
source String?
completedLangs String?

// we need both indexes on en_string+category and separately on category
@@index([en_string, category])
@@index([category])
}

If you want more languages, just add more fields like uk_string, ja_string, fr_string, es_string to the model.

Next, add resource for translations:

./resources/translations.ts

import AdminForth, { AdminForthDataTypes, AdminForthResourceInput } from "adminforth";
import CompletionAdapterOpenAIChatGPT from "@adminforth/completion-adapter-open-ai-chat-gpt";
import I18nPlugin from "@adminforth/i18n";
import { v1 as uuid } from "uuid";


export default {
dataSource: "maindb",
table: "translations",
resourceId: "translations",
label: "Translations",

recordLabel: (r: any) => `โœ๏ธ ${r.en_string}`,
plugins: [
new I18nPlugin({
supportedLanguages: ['en', 'uk', 'ja', 'fr'],

// names of the fields in the resource which will store translations
translationFieldNames: {
en: 'en_string',
uk: 'uk_string',
ja: 'ja_string',
fr: 'fr_string',
},

// name of the field which will store the category of the string
// this helps to categorize strings and deliver them efficiently
categoryFieldName: 'category',

// optional field to store the source (e.g. source file name)
sourceFieldName: 'source',

// optional field store list of completed translations
// will hel to filter out incomplete translations
completedFieldName: 'completedLangs',

completeAdapter: new CompletionAdapterOpenAIChatGPT({
openAiApiKey: process.env.OPENAI_API_KEY as string,
model: 'gpt-4o-mini',
expert: {
// for UI translation it is better to lower down the temperature from default 0.7. Less creative and more accurate
temperature: 0.5,
},
}),
}),

],
options: {
listPageSize: 30,
},
columns: [
{
name: "id",
fillOnCreate: ({ initialRecord, adminUser }: any) => uuid(),
primaryKey: true,
showIn: [],
},
{
name: "en_string",
type: AdminForthDataTypes.STRING,
label: 'English',
},
{
name: "created_at",
fillOnCreate: ({ initialRecord, adminUser }: any) => new Date().toISOString(),
},
{
name: "uk_string",
type: AdminForthDataTypes.STRING,
label: 'Ukrainian',
},
{
name: "ja_string",
type: AdminForthDataTypes.STRING,
label: 'Japanese',
},
{
name: "fr_string",
type: AdminForthDataTypes.STRING,
label: 'French',
},
{
name: "completedLangs",
},
{
name: "source",
showIn: ['filter', 'show'],
type: AdminForthDataTypes.STRING,
},
{
name: "category",
showIn: ['filter', 'show', 'list'],
type: AdminForthDataTypes.STRING,
}
],
} as AdminForthResourceInput;

Add OPENAI_API_KEY to your .env file:

OPENAI_API_KEY=your_openai_api_key

Also add the resource to main file and add menu item in ./index.ts:

./index.ts

import translations from "./resources/translations";
...

const adminForth = new AdminForth({
...
resources: [
...
translations,
],
menu: [
...
{
label: 'Translations',
icon: 'material-symbols:translate',
resourceId: 'translations',
},
],
...
});

This is it, now you should restart your app and see the translations resource in the menu.

You can add translations for each language manually or use Bulk actions to generate translations with AI completion adapter.

For simplicity you can also use filter to get only untranslated strings and complete them one by one (filter name "Fully translated" in the filter).

Translation for custom componentsโ€‹

To translate custom components, you should simply wrap all strings in $t function. For example:

Now create file CustomLoginFooter.vue in the custom folder of your project:

./custom/CustomLoginFooter.vue
<template>
<div class="text-center text-gray-500 text-sm mt-4">
By logging in, you agree to our <a href="#" class="text-blue-500">Terms of Service</a> and <a href="#" class="text-blue-500">Privacy Policy</a>
{{$t('By logging in, you agree to our')}} <a href="#" class="text-blue-500">{{$t('Terms of Service')}}</a> {{$t('and')}} <a href="#" class="text-blue-500">{{$t('Privacy Policy')}}</a>
</div>
</template>

Variables in frontend translationsโ€‹

You can use variables in translations in same way like you would do it with vue-i18n library.

This is generally helps to understand the context of the translation for AI completion adapters and simplifies the translation process, even if done manually.

For example if you have string "Showing 1 to 10 of 100 entries" you can of course simply do

{{ $t('Showing')}} {{from}} {{$t('to')}} {{to}} {{$t('of')}} {{total}} {{$t('entries') }}

And it will form 4 translation strings. But it is much better to have it as single string with variables like this:

{{ $t('Showing {from} to {to} of {total} entries', { from, to, total } ) }}

For example, let's add user greeting to the header.

./custom/Header.vue
<template>
<div class="flex items-center justify-between p-4 bg-white shadow-md">
<div class="text-lg font-semibold text-gray-800">
{{ $t('Welcome, {name}', { name: adminUser.username }) }}
</div>
</div>
</template>

<script setup lang="ts">
import type { AdminForthResourceColumnCommon, AdminForthResourceCommon, AdminUser } from '@/types/Common';

const props = defineProps<{
column: AdminForthResourceColumnCommon;
record: any;
meta: any;
resource: AdminForthResourceCommon;
adminUser: AdminUser
}>();
</script>

How to use such component

./index.ts

const adminForth = new AdminForth({
...
customization{
globalInjections: {
header: {
file: '@@/Header.vue',
},
}
},
...
});

HTML in translationsโ€‹

Sometimes you might want to have HTML in translations. You can use i18n-t translation component for this. For example:

<span class="hidden sm:inline">
<i18n-t keypath="Showing {from} to {to} of {total} Entries" tag="p" >
<template v-slot:from>
<strong>{{ from }}</strong>
</template>
<template v-slot:to>
<strong>{{ to }}</strong>
</template>
<template v-slot:total>
<strong>{{ totalRows }}</strong>
</template>
</i18n-t>
</span>

Pluralizationโ€‹

Frontend uses same pluralization rules as vue-i18n library. You can use it in the same way. For example:

{{ $t('Apartment last 7 days | Apartments last 7 days', data.totalAparts) }}

For English it will use 2 pluralization forms (1 and other), for Slavic languages, LLM adapter will be instructed to generate 4 forms via Pipe: for zero, for one, for 2-4 and for 5+:

alt text

๐Ÿšง Cheaper LLM models like gpt-4o-mini might still purely generate pluralisation slavic forms, even despite the fact plugin carefully instructs the model and gives it examples. So if you are using cheap model we recommend reviewing generated translations for plurals and fixing them manually. You can use filter with '|' parameter to filter out strings with pluralisation.

Limiting access to translatingโ€‹

If you want to limit access to translations resource only to developers or translators, you can use limiting access feature.

Please note that access to "Translate selected" bulk action which uses LLM AI translation adapter is determined by allowedActions.edit permission of resource.

Translations in custom APIsโ€‹

Sometimes you need to return a translated error or success message from your API. You can use special tr function for this.

For simple example let's move previous example to format string on the backend side:

./custom/Header.vue
<template>
<div class="flex items center justify-between p-4 bg-white shadow-md">
<div class="text-lg font-semibold text-gray-800">
{{ greeting }}
</div>
</div>
</template>

<script setup lang="ts">
import type { AdminForthResourceColumnCommon, AdminForthResourceCommon, AdminUser } from '@/types/Common';
import { callApi } from '@/utils';
import { ref, onMounted } from 'vue';
import adminforth from '@/adminforth';

const greeting: Ref<string> = ref('');

onMounted(async () => {
try {
const data = await callApi({path: '/api/greeting', method: 'GET'});
greeting.value = data.text;
} catch (error) {
adminforth.alert({
message: `Error fetching data: ${error.message}`,
variant: 'danger',
timeout: 'unlimited'
});
return;
}
})
</script>

And on the backend side you can use tr function to translate the string:

./index.ts
  app.get(`${ADMIN_BASE_URL}/api/greeting`, 
admin.express.authorize(
admin.express.translatable(
async (req, res) => {
res.json({
text: await req.tr('Welcome, {name}', 'customApis', { name: req.adminUser.username }),
});
}
)
)
);

// serve after you added all api
admin.discoverDatabases();
admin.express.serve(app)

As you can see we should use admin.express.translatable middleware which will inject tr function to the request object. First param is the string to translate, second is the category of the string (actually you can use any string here), and the third is the variables object.

If you don't use params, you can use tr without third param:

{
text: await tr('Welcome, dear user', 'customApis'),
}

๐Ÿ™…โ€โ™‚๏ธ Temporary limitation: For now all translations strings for backend (adminforth internal and for from custom APIs) appear in Translations resource and table only after they are used. So greeting string will appear in the Translations table only after the first request to the API which reaches the tr function call. So to collect all translations you should use your app for some time and make sure all strings are used at In future we plan to add backend strings collection in same way like frontend strings are collected.

Pluralisation in backend translationsโ€‹

In same way you can use pluralisation in backend translations:

./index.ts
await req.tr('{count} apartment last 7 days | {count} apartments last 7 days', 'customApis', { count: totalAparts }, totalAparts) 

Please pay attention that you should pass totalAparts as last argument to tr function.

If you don't have parameters, you can pass empty object as 3rd argument:

./index.ts
await req.tr('Apartment last 7 days | Apartments last 7 days', 'customApis', {}, totalAparts) 

Pluralisation rules are the same as in frontend

Translating messages within bulk actionโ€‹

Label and confirm strings of bulk actions are already translated by AdminForth, but succesMessage returned by action function should be translated manually because of the dynamic nature of the message.

Let's rework the bulk action from bulkActions example to use translations:

./resources/apartments.ts
import { AdminUser } from 'adminforth';
import { admin } from '../index';

{
...
resourceId: 'aparts',
...
options: {
bulkActions: [
{
label: 'Mark as listed',
icon: 'flowbite:eye-solid',
// if optional `confirm` is provided, user will be asked to confirm action
confirm: 'Are you sure you want to mark all selected apartments as listed?',
action: function ({selectedIds, adminUser }: {selectedIds: any[], adminUser: AdminUser }) {
const stmt = admin.resource('aparts').dataConnector.db.prepare(`UPDATE apartments SET listed = 1 WHERE id IN (${selectedIds.map(() => '?').join(',')})`);
stmt.run(...selectedIds);
return { ok: true, error: false, successMessage: `Marked ${selectedIds.length} apartments as listed` };
return { ok: true, error: false, successMessage: await tr('Marked {count} apartments as listed', 'apartments', { count: selectedIds.length }) };
},
}
],
}
}

Translating external applicationโ€‹

You can use this module not only to translate admin area of your application but also to translate other parts like SEO-facing or user-facing services. This will allow you to reuse the same functionality and AI completion adapters for all your translations and manage them in one place. For example in this app we will consider translating a Nuxt.js SEO-centric frontend which we want to translate with vue-i18n.

To do it you need to use 2 exposed methods from the plugin: feedCategoryTranslations and getCategoryTranslations.

First of all, at some step, e.g. CI pipeline you should get all translation strings from your external app and feed them an own rest API like '/feed-nuxt-strings', this API might look like this

./index.ts
  app.post(`${ADMIN_BASE_URL}/feed-nuxt-strings`, 
async (req, res) => {
// req.body will be an array of objects like:
// [{
// path: 'Login',
// file: 'src/views/Login.vue:35',
// }]

const messagesForFeed = req.body.missingKeys.map((mk: any) => {
return {
en_string: mk.path,
source: mk.file,
};
});

admin.getPluginByClassName<I18nPlugin>('I18nPlugin').feedCategoryTranslations(
messagesForFeed,
'nextApp'
)

res.json({
ok: true
});
}
);

For extracting 18n messages we use vue-i18n-extract package. You can add extract command to package.json:

{
"scripts": {
"i18n:extract": "echo '{}' > i18n-empty.json && vue-i18n-extract report --vueFiles './src/**/*.?(js|vue)' --output ./i18n-messages.json --languageFiles 'i18n-empty.json' --add",
"i18n:feed-to-backoffice": "npm run i18n:extract && curl -X POST -H 'Content-Type: application/json' -d @i18n-messages.json http://adminforth:3000/feed-nuxt-strings"
}
}

Make sure to replace adminforth:3000 with AdminForth API URL. We are assuming it is docker container name in internal docker network.

So in the pipeline you should run npm run i18n:feed-to-backoffice to extract messages from your Nuxt.js app and feed them to AdminForth.

๐Ÿ‘† The example method is just a stub, please make sure you not expose endpoint to public or add some simple authorization on it, otherwise someone might flood you with dump translations requests.

Then in your Nuxt.js app you should call this API and store the strings in the same.

Next part. When we will need translations on the nuxt instance, we should use vue-i18n's lazy loading feature:

./your-nuxt-app-source-file.ts
import { callAdminForthApi } from '@/utils';

export async function loadLocaleMessages(i18n, locale) {
// load locale messages with dynamic import
const messages = await callAdminForthApi({
path: `/api/translations/?lang=${locale}`,
method: 'GET',
});

// set locale and locale message
i18n.global.setLocaleMessage(locale, messages.default)

return nextTick()
}

See vue-i18n's lazy loading feature to understand where better to call loadLocaleMessages function.

Here is how API for messages will look:

./index.ts
app.get(`${ADMIN_BASE_URL}/api/translations/`, 
async (req, res) => {
const lang = req.query.lang;
const messages = await admin.getPluginByClassName<I18nPlugin>('I18nPlugin').getCategoryTranslations('nextApp', lang);
res.json(messages);
}
);

Get language namesโ€‹

Also you can use handy method to get language names in native and English form with emoji flags:

./api.ts
const languages = await admin.getPluginByClassName<I18nPlugin>('I18nPlugin').languagesList();

Response will look like this:

  "languages": [
{
"code": "en",
"nameOnNative": "English",
"nameEnglish": "English",
"emojiFlag": "๐Ÿ‡บ๐Ÿ‡ธ"
},
{
"code": "uk",
"nameOnNative": "ะฃะบั€ะฐั—ะฝััŒะบะฐ",
"nameEnglish": "Ukrainian",
"emojiFlag": "๐Ÿ‡บ๐Ÿ‡ฆ"
},
{
"code": "ar",
"nameOnNative": "ุงู„ุนุฑุจูŠุฉ",
"nameEnglish": "Arabic",
"emojiFlag": "๐Ÿ‡ฆ๐Ÿ‡ท"
},