Skip to content

Commit 2c3614a

Browse files
committed
add "Translating external app" guide
1 parent 5b66bb3 commit 2c3614a

File tree

12 files changed

+194
-77
lines changed

12 files changed

+194
-77
lines changed

Changelog.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [v1.5.8] - next
8+
## [v1.5.9] - next
99

10-
# Added
10+
## [v1.5.8]
11+
12+
### Added
1113

1214
- Command to generate typescript models `npx -y adminforth generate-models --env-file=.env`
1315
- add i18n support: add vue-i18n to frontend and tr function to backend. This will allow to implement translation plugins
@@ -18,21 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1820
- make user menu switch shorter
1921
- next param support on login route (also preserve next param between login/signup navigation)
2022

21-
# Fixed
22-
23-
- favicon when using BaseURL
24-
25-
26-
# Improved
23+
### Improved
2724

28-
- Added separate BeforeCreateSave function in types without oldRecord and make oldRecord Mandatory in existing BeforeSaveFunction
29-
25+
- Added separate BeforeCreateSave function in types without oldRecord and make oldRecord mandatory in existing BeforeSaveFunction
3026
- Added dataConnector: IAdminForthDataSourceConnectorBase; into IOperationalResource - for reusing connectors from users code
3127

32-
# Fixed
28+
### Fixed
3329

3430
- WS on base URL
35-
31+
- favicon when using BaseURL
32+
- Mongo: fix search by strings with "+" and other special characters
33+
- mongo storing boolean as true/false now. Before it was 1/0 which broke compatibility with many other ORMs
3634

3735
## [v1.5.7] - 2024-12-09
3836

adminforth/documentation/docs/tutorial/05-Plugins/10-i18n.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,96 @@ import { admin } from '../index';
418418
],
419419
}
420420
}
421-
```
421+
```
422+
423+
## Translating external application
424+
425+
You can use this module not only to translate Admin area of your application but also to translate other services of your application.
426+
This will allow you to reuse the same functionality and AI completion adapters for all your translations. For example in this app we
427+
will consider that we have a Nuxt.js SEO-centric frontend which we want to translate with [vue-i18n](https://vue-i18n.intlify.dev/).
428+
429+
To do it you need to use 2 function from the plugin. First at some step, e.g. CI pipeline you should get all translation strings from your external app (e.g. Nuxt.js frontend) and create an own rest API like `'/feed-nuxt-strings'`, this API might look like this
430+
431+
For extracting 18n messages we use [vue-i18n-extract](https://github.com/Spittal/vue-i18n-extract) package.
432+
You can add extract command to `package.json`:
433+
434+
```json
435+
{
436+
"scripts": {
437+
"i18n:extract": "echo '{}' > i18n-empty.json && vue-i18n-extract report --vueFiles './src/**/*.?(js|vue)' --output ./i18n-messages.json --languageFiles 'i18n-empty.json' --add",
438+
"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"
439+
""
440+
}
441+
}
442+
```
443+
444+
Make sure to replace `adminforth:3000` with AdminForth API URL.
445+
446+
447+
```ts title="./index.ts"
448+
app.get(`${ADMIN_BASE_URL}/feed-nuxt-strings`,
449+
async (req, res) => {
450+
// req.body will be an array of objects like:
451+
// [{
452+
// path: 'Login',
453+
// file: 'src/views/Login.vue:35',
454+
// }]
455+
456+
const messagesForFeed = req.body.map((mk: any) => {
457+
return {
458+
en_string: mk.path,
459+
source: mk.file,
460+
};
461+
});
462+
463+
admin.getPluginByClassName<I18nPlugin>('I18nPlugin').feedCategoryTranslations(
464+
messagesForFeed,
465+
'nextApp'
466+
)
467+
468+
res.json({
469+
ok: true
470+
});
471+
}
472+
);
473+
474+
```
475+
476+
> 👆 This example method is just a stub, please make sure you not expose it to public or add some simple authorization on it,
477+
> otherwise someone might flood you with dump translations requests.
478+
479+
Then in your Nuxt.js app you should call this API and store the strings in the same.
480+
481+
Next part. When we will need translations on the nuxt instance, we should use [vue-i18n's lazy loading feature](https://vue-i18n.intlify.dev/guide/advanced/lazy):
482+
483+
```typescript title="./your-buxt-app-source-file.ts"
484+
import { callAdminForthApi } from '@/utils';
485+
486+
export async function loadLocaleMessages(i18n, locale) {
487+
// load locale messages with dynamic import
488+
const messages = await callAdminForthApi({
489+
path: `/api/translations/?lang=${locale}`,
490+
method: 'GET',
491+
});
492+
493+
// set locale and locale message
494+
i18n.global.setLocaleMessage(locale, messages.default)
495+
496+
return nextTick()
497+
}
498+
```
499+
500+
See [vue-i18n's lazy loading feature](https://vue-i18n.intlify.dev/guide/advanced/lazy) to understand where better to call `loadLocaleMessages` function.
501+
502+
Here is how API for messages will look:
503+
504+
```ts title="./index.ts"
505+
app.get(`${ADMIN_BASE_URL}/api/translations/`,
506+
async (req, res) => {
507+
const lang = req.query.lang;
508+
const messages = await admin.getPluginByClassName<I18nPlugin>('I18nPlugin').getCategoryTranslations('nextApp', lang);
509+
res.json(messages);
510+
}
511+
);
512+
```
513+

adminforth/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

adminforth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "adminforth",
3-
"version": "1.5.8-next.27",
3+
"version": "1.5.8",
44
"description": "OpenSource Vue3 powered forth-generation admin panel",
55
"main": "dist/index.js",
66
"module": "dist/index.js",

adminforth/plugins/i18n/Changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [1.0.21] - next
99

10+
### Fixed
11+
- improve cache reset when editing messages manually
12+
13+
### Added
14+
- Translating external app" feature by using feedCategoryTranslations
15+
1016
## [1.0.20]
1117

1218
### Fixed

adminforth/plugins/i18n/index.ts

Lines changed: 68 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -251,18 +251,15 @@ export default class I18N extends AdminForthPlugin {
251251
if (lang === 'en') {
252252
continue;
253253
}
254-
if (updates[this.trFieldNames[lang]]) {
254+
if (updates[this.trFieldNames[lang]] !== undefined) {
255255
langsChanged.push(lang);
256256
}
257257
}
258258

259259
// clear frontend cache for all langsChanged
260260
for (const lang of langsChanged) {
261-
if (oldRecord[this.options.categoryFieldName] === 'frontend') {
262-
this.cache.clear(`${this.resourceConfig.resourceId}:frontend:${lang}`);
263-
} else {
264-
this.cache.clear(`${this.resourceConfig.resourceId}:${oldRecord[this.options.categoryFieldName]}:${lang}:${oldRecord[this.enFieldName]}`);
265-
}
261+
this.cache.clear(`${this.resourceConfig.resourceId}:${oldRecord[this.options.categoryFieldName]}:${lang}`);
262+
this.cache.clear(`${this.resourceConfig.resourceId}:${oldRecord[this.options.categoryFieldName]}:${lang}:${oldRecord[this.enFieldName]}`);
266263
}
267264
this.updateUntranslatedMenuBadge();
268265
}
@@ -274,11 +271,8 @@ export default class I18N extends AdminForthPlugin {
274271
resourceConfig.hooks.delete.afterSave.push(async ({ record }: { record: any }): Promise<{ ok: boolean, error?: string }> => {
275272
for (const lang of this.options.supportedLanguages) {
276273
// if frontend, clear frontend cache
277-
if (record[this.options.categoryFieldName] === 'frontend') {
278-
this.cache.clear(`${this.resourceConfig.resourceId}:frontend:${lang}`);
279-
} else {
280-
this.cache.clear(`${this.resourceConfig.resourceId}:${record[this.options.categoryFieldName]}:${lang}:${record[this.enFieldName]}`);
281-
}
274+
this.cache.clear(`${this.resourceConfig.resourceId}:${record[this.options.categoryFieldName]}:${lang}`);
275+
this.cache.clear(`${this.resourceConfig.resourceId}:${record[this.options.categoryFieldName]}:${lang}:${record[this.enFieldName]}`);
282276
}
283277
this.updateUntranslatedMenuBadge();
284278
return { ok: true };
@@ -609,39 +603,16 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
609603
return;
610604
}
611605
// loop over missingKeys[i].path and add them to database if not exists
612-
613-
const missingKeysDeduplicated = messages.missingKeys.reduce((acc: any[], missingKey: any) => {
614-
if (!acc.find((a) => a.path === missingKey.path)) {
615-
acc.push(missingKey);
616-
}
617-
return acc;
618-
}, []);
619-
620-
await Promise.all(missingKeysDeduplicated.map(async (missingKey: any) => {
621-
const key = missingKey.path;
622-
const file = missingKey.file;
623-
const category = 'frontend';
624-
const exists = await adminforth.resource(this.resourceConfig.resourceId).count(Filters.EQ(this.enFieldName, key));
625-
if (exists) {
626-
return;
627-
}
628-
if (!key) {
629-
throw new Error(`Faced an empty key in Fronted messages, file ${file}`);
630-
}
631-
const record = {
632-
[this.enFieldName]: key,
633-
[this.options.categoryFieldName]: category,
634-
...(this.options.sourceFieldName ? { [this.options.sourceFieldName]: file } : {}),
606+
const messagesForFeed = messages.missingKeys.map((mk) => {
607+
return {
608+
en_string: mk.path,
609+
source: mk.file,
635610
};
636-
try {
637-
await adminforth.resource(this.resourceConfig.resourceId).create(record);
638-
} catch (e) {
639-
console.error('🐛 Error creating record', e);
640-
}
641-
}));
611+
});
642612

643-
// updateBadge
644-
this.updateUntranslatedMenuBadge()
613+
await this.feedCategoryTranslations(messagesForFeed, 'frontend')
614+
615+
645616
}
646617

647618

@@ -770,6 +741,60 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
770741
return `single`;
771742
}
772743

744+
async getCategoryTranslations(category: string, lang: string): Promise<Record<string, string>> {
745+
const resource = this.adminforth.resource(this.resourceConfig.resourceId);
746+
const cacheKey = `${this.resourceConfig.resourceId}:${category}:${lang}`;
747+
const cached = await this.cache.get(cacheKey);
748+
if (cached) {
749+
return cached;
750+
}
751+
const translations = {};
752+
const allTranslations = await resource.list([Filters.EQ(this.options.categoryFieldName, category)]);
753+
for (const tr of allTranslations) {
754+
translations[tr[this.enFieldName]] = tr[this.trFieldNames[lang]];
755+
}
756+
await this.cache.set(cacheKey, translations);
757+
return translations;
758+
}
759+
760+
async feedCategoryTranslations(messages: {
761+
en_string: string;
762+
source: string;
763+
}[], category: string): Promise<void> {
764+
const adminforth = this.adminforth;
765+
const missingKeysDeduplicated = messages.reduce((acc: any[], missingKey: any) => {
766+
if (!acc.find((a) => a.en_string === missingKey.en_string)) {
767+
acc.push(missingKey);
768+
}
769+
return acc;
770+
}, []);
771+
772+
await Promise.all(missingKeysDeduplicated.map(async (missingKey: any) => {
773+
const key = missingKey.en_string;
774+
const source = missingKey.source;
775+
const exists = await adminforth.resource(this.resourceConfig.resourceId).count(Filters.EQ(this.enFieldName, key));
776+
if (exists) {
777+
return;
778+
}
779+
if (!key) {
780+
console.error(`Faced an empty key in feeding ${category} messages, source ${source}`);
781+
}
782+
const record = {
783+
[this.enFieldName]: key,
784+
[this.options.categoryFieldName]: category,
785+
...(this.options.sourceFieldName ? { [this.options.sourceFieldName]: source } : {}),
786+
};
787+
try {
788+
await adminforth.resource(this.resourceConfig.resourceId).create(record);
789+
} catch (e) {
790+
console.error('🐛 Error creating record', e);
791+
}
792+
}));
793+
794+
// updateBadge
795+
this.updateUntranslatedMenuBadge();
796+
}
797+
773798

774799
setupEndpoints(server: IHttpServer) {
775800
server.endpoint({
@@ -780,18 +805,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
780805
const lang = query.lang;
781806

782807
// form map of translations
783-
const resource = this.adminforth.resource(this.resourceConfig.resourceId);
784-
const cacheKey = `${this.resourceConfig.resourceId}:frontend:${lang}`;
785-
const cached = await this.cache.get(cacheKey);
786-
if (cached) {
787-
return cached;
788-
}
789-
const translations = {};
790-
const allTranslations = await resource.list([Filters.EQ(this.options.categoryFieldName, 'frontend')]);
791-
for (const tr of allTranslations) {
792-
translations[tr[this.enFieldName]] = tr[this.trFieldNames[lang]];
793-
}
794-
await this.cache.set(cacheKey, translations);
808+
const translations = await this.getCategoryTranslations('frontend', lang);
795809
return translations;
796810

797811
}

adminforth/plugins/i18n/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

adminforth/plugins/i18n/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@adminforth/i18n",
3-
"version": "1.0.20-next.0",
3+
"version": "1.0.21-next.1",
44
"main": "dist/index.js",
55
"types": "dist/index.d.ts",
66
"type": "module",

adminforth/plugins/open-signup/Changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## [v1.0.9]
2+
3+
# Fixed
4+
- when user created normalize email to lowercase. This is not needed when right email validator is set on email field because
5+
it will not allow to create such email, but if user forgot to set it it might save situation
16

27
## [v1.0.8]
38

adminforth/plugins/open-signup/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ export default class OpenSignupPlugin extends AdminForthPlugin {
227227
}
228228
}
229229
}
230-
230+
231+
// This is not needed when right email validator is set on email field because
232+
// it will not allow to create such email, but if user forgot to set it it might save situation
231233
const normalizedEmail = email.toLowerCase(); // normalize email
232234

233235
// first check again if email already exists

0 commit comments

Comments
 (0)