@adminforth/i18n 1.3.7 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build.log CHANGED
@@ -17,5 +17,5 @@ custom/package-lock.json
17
17
  custom/package.json
18
18
  custom/tsconfig.json
19
19
 
20
- sent 32,187 bytes received 248 bytes 64,870.00 bytes/sec
21
- total size is 31,254 speedup is 0.96
20
+ sent 32,912 bytes received 248 bytes 66,320.00 bytes/sec
21
+ total size is 31,979 speedup is 0.96
@@ -3,13 +3,15 @@
3
3
  <script setup>
4
4
  import { onMounted } from 'vue';
5
5
  import { useI18n } from 'vue-i18n';
6
- import { setLang, getLocalLang } from './langCommon';
6
+ import { setLang, getLocalLang, setLocalLang } from './langCommon';
7
7
 
8
8
  const { setLocaleMessage, locale } = useI18n();
9
9
 
10
10
  const props = defineProps(['meta']);
11
11
 
12
12
  onMounted(() => {
13
- setLang({ setLocaleMessage, locale }, props.meta.pluginInstanceId, getLocalLang(props.meta.supportedLanguages));
13
+ const langIso = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
14
+ setLocalLang(langIso);
15
+ setLang({ setLocaleMessage, locale }, props.meta.pluginInstanceId, langIso);
14
16
  });
15
17
  </script>
@@ -70,9 +70,10 @@ function doChangeLang(lang) {
70
70
 
71
71
  const options = computed(() => {
72
72
  return props.meta.supportedLanguages.map((lang) => {
73
+ const region = String(lang.code).split('-')[1]?.toUpperCase();
73
74
  return {
74
75
  value: lang.code,
75
- label: lang.name,
76
+ label: region ? `${lang.name} (${region})` : lang.name,
76
77
  };
77
78
  });
78
79
  });
@@ -87,6 +88,6 @@ const selectedOption = computed(() => {
87
88
 
88
89
 
89
90
  onMounted(() => {
90
- selectedLanguage.value = getLocalLang(props.meta.supportedLanguages);
91
+ selectedLanguage.value = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
91
92
  });
92
93
  </script>
@@ -53,14 +53,15 @@ watch(() => selectedLanguage.value, async (newVal) => {
53
53
 
54
54
  const options = computed(() => {
55
55
  return props.meta.supportedLanguages.map((lang) => {
56
+ const region = String(lang.code).split('-')[1]?.toUpperCase();
56
57
  return {
57
58
  value: lang.code,
58
- label: lang.name,
59
+ label: region ? `${lang.name} (${region})` : lang.name,
59
60
  };
60
61
  });
61
62
  });
62
63
 
63
64
  onMounted(() => {
64
- selectedLanguage.value = getLocalLang(props.meta.supportedLanguages);
65
+ selectedLanguage.value = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
65
66
  });
66
67
  </script>
@@ -4,6 +4,7 @@ import dayjsLocales from './dayjsLocales';
4
4
  import datepickerLocales from './datepickerLocales';
5
5
  import dayjs from 'dayjs';
6
6
  import Datepicker from "flowbite-datepicker/Datepicker";
7
+ import type { SupportedLanguage } from '../types';
7
8
 
8
9
 
9
10
  const messagesCache: Record<
@@ -78,13 +79,17 @@ const countryISO31661ByLangISO6391 = {
78
79
  };
79
80
 
80
81
  export function getCountryCodeFromLangCode(langCode) {
81
- return countryISO31661ByLangISO6391[langCode] || langCode;
82
+ const [primary, region] = String(langCode).split('-');
83
+ if (region && /^[A-Za-z]{2}$/.test(region)) {
84
+ return region.toLowerCase();
85
+ }
86
+ return countryISO31661ByLangISO6391[primary] || primary;
82
87
  }
83
88
 
84
89
 
85
90
  const LS_LANG_KEY = `afLanguage`;
86
91
 
87
- export function getLocalLang(supportedLanguages: {code}[]): string {
92
+ export function getLocalLang(supportedLanguages: {code}[], primaryLanguage?: SupportedLanguage): string {
88
93
  let lsLang = localStorage.getItem(LS_LANG_KEY);
89
94
  // if someone screwed up the local storage or we stopped language support, lets check if it is in supported languages
90
95
  if (lsLang && !supportedLanguages.find((l) => l.code == lsLang)) {
@@ -99,6 +104,10 @@ export function getLocalLang(supportedLanguages: {code}[]): string {
99
104
  if (foundLang) {
100
105
  return foundLang.code;
101
106
  }
107
+ if (primaryLanguage && supportedLanguages.find((l) => l.code == primaryLanguage)) {
108
+ return primaryLanguage;
109
+ }
110
+
102
111
  return supportedLanguages[0].code;
103
112
  }
104
113
 
@@ -3,13 +3,15 @@
3
3
  <script setup>
4
4
  import { onMounted } from 'vue';
5
5
  import { useI18n } from 'vue-i18n';
6
- import { setLang, getLocalLang } from './langCommon';
6
+ import { setLang, getLocalLang, setLocalLang } from './langCommon';
7
7
 
8
8
  const { setLocaleMessage, locale } = useI18n();
9
9
 
10
10
  const props = defineProps(['meta']);
11
11
 
12
12
  onMounted(() => {
13
- setLang({ setLocaleMessage, locale }, props.meta.pluginInstanceId, getLocalLang(props.meta.supportedLanguages));
13
+ const langIso = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
14
+ setLocalLang(langIso);
15
+ setLang({ setLocaleMessage, locale }, props.meta.pluginInstanceId, langIso);
14
16
  });
15
17
  </script>
@@ -70,9 +70,10 @@ function doChangeLang(lang) {
70
70
 
71
71
  const options = computed(() => {
72
72
  return props.meta.supportedLanguages.map((lang) => {
73
+ const region = String(lang.code).split('-')[1]?.toUpperCase();
73
74
  return {
74
75
  value: lang.code,
75
- label: lang.name,
76
+ label: region ? `${lang.name} (${region})` : lang.name,
76
77
  };
77
78
  });
78
79
  });
@@ -87,6 +88,6 @@ const selectedOption = computed(() => {
87
88
 
88
89
 
89
90
  onMounted(() => {
90
- selectedLanguage.value = getLocalLang(props.meta.supportedLanguages);
91
+ selectedLanguage.value = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
91
92
  });
92
93
  </script>
@@ -53,14 +53,15 @@ watch(() => selectedLanguage.value, async (newVal) => {
53
53
 
54
54
  const options = computed(() => {
55
55
  return props.meta.supportedLanguages.map((lang) => {
56
+ const region = String(lang.code).split('-')[1]?.toUpperCase();
56
57
  return {
57
58
  value: lang.code,
58
- label: lang.name,
59
+ label: region ? `${lang.name} (${region})` : lang.name,
59
60
  };
60
61
  });
61
62
  });
62
63
 
63
64
  onMounted(() => {
64
- selectedLanguage.value = getLocalLang(props.meta.supportedLanguages);
65
+ selectedLanguage.value = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
65
66
  });
66
67
  </script>
@@ -4,6 +4,7 @@ import dayjsLocales from './dayjsLocales';
4
4
  import datepickerLocales from './datepickerLocales';
5
5
  import dayjs from 'dayjs';
6
6
  import Datepicker from "flowbite-datepicker/Datepicker";
7
+ import type { SupportedLanguage } from '../types';
7
8
 
8
9
 
9
10
  const messagesCache: Record<
@@ -78,13 +79,17 @@ const countryISO31661ByLangISO6391 = {
78
79
  };
79
80
 
80
81
  export function getCountryCodeFromLangCode(langCode) {
81
- return countryISO31661ByLangISO6391[langCode] || langCode;
82
+ const [primary, region] = String(langCode).split('-');
83
+ if (region && /^[A-Za-z]{2}$/.test(region)) {
84
+ return region.toLowerCase();
85
+ }
86
+ return countryISO31661ByLangISO6391[primary] || primary;
82
87
  }
83
88
 
84
89
 
85
90
  const LS_LANG_KEY = `afLanguage`;
86
91
 
87
- export function getLocalLang(supportedLanguages: {code}[]): string {
92
+ export function getLocalLang(supportedLanguages: {code}[], primaryLanguage?: SupportedLanguage): string {
88
93
  let lsLang = localStorage.getItem(LS_LANG_KEY);
89
94
  // if someone screwed up the local storage or we stopped language support, lets check if it is in supported languages
90
95
  if (lsLang && !supportedLanguages.find((l) => l.code == lsLang)) {
@@ -99,6 +104,10 @@ export function getLocalLang(supportedLanguages: {code}[]): string {
99
104
  if (foundLang) {
100
105
  return foundLang.code;
101
106
  }
107
+ if (primaryLanguage && supportedLanguages.find((l) => l.code == primaryLanguage)) {
108
+ return primaryLanguage;
109
+ }
110
+
102
111
  return supportedLanguages[0].code;
103
112
  }
104
113
 
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock } from "adminforth";
11
11
  import iso6391 from 'iso-639-1';
12
+ import { iso31661Alpha2ToAlpha3 } from 'iso-3166';
12
13
  import path from 'path';
13
14
  import fs from 'fs-extra';
14
15
  import chokidar from 'chokidar';
@@ -37,9 +38,27 @@ const countryISO31661ByLangISO6391 = {
37
38
  uk: 'ua', // Ukrainian → Ukraine
38
39
  ur: 'pk', // Urdu → Pakistan
39
40
  };
40
- function getCountryCodeFromLangCode(langCode) {
41
+ function getCountryCodeFromLangCode(lang) {
42
+ const [langCode, region] = String(lang).split('-');
43
+ if (region && /^[A-Z]{2}$/.test(region)) {
44
+ return region.toLowerCase();
45
+ }
41
46
  return countryISO31661ByLangISO6391[langCode] || langCode;
42
47
  }
48
+ function getPrimaryLanguageCode(langCode) {
49
+ return String(langCode).split('-')[0];
50
+ }
51
+ function isValidSupportedLanguageTag(langCode) {
52
+ const [primary, region] = String(langCode).split('-');
53
+ if (!iso6391.validate(primary)) {
54
+ return false;
55
+ }
56
+ if (!region) {
57
+ return true;
58
+ }
59
+ const regionUpper = region.toUpperCase();
60
+ return /^[A-Z]{2}$/.test(regionUpper) && (regionUpper in iso31661Alpha2ToAlpha3);
61
+ }
43
62
  class CachingAdapterMemory {
44
63
  constructor() {
45
64
  this.cache = {};
@@ -84,6 +103,7 @@ export default class I18nPlugin extends AdminForthPlugin {
84
103
  this.options = options;
85
104
  this.cache = new CachingAdapterMemory();
86
105
  this.trFieldNames = {};
106
+ this.primaryLanguage = options.primaryLanguage || 'en';
87
107
  }
88
108
  computeCompletedFieldValue(record) {
89
109
  return __awaiter(this, void 0, void 0, function* () {
@@ -119,10 +139,10 @@ export default class I18nPlugin extends AdminForthPlugin {
119
139
  return __awaiter(this, void 0, void 0, function* () {
120
140
  var _a, _b;
121
141
  _super.modifyResourceConfig.call(this, adminforth, resourceConfig);
122
- // check each supported language is valid ISO 639-1 code
142
+ // validate each supported language: ISO 639-1 or BCP-47 with region (e.g., en-GB)
123
143
  this.options.supportedLanguages.forEach((lang) => {
124
- if (!iso6391.validate(lang)) {
125
- throw new Error(`Invalid language code ${lang}, please define valid ISO 639-1 language code (2 lowercase letters)`);
144
+ if (!isValidSupportedLanguageTag(lang)) {
145
+ throw new Error(`Invalid language code ${lang}. Use ISO 639-1 (e.g., 'en') or BCP-47 with region (e.g., 'en-GB').`);
126
146
  }
127
147
  });
128
148
  this.externalAppOnly = this.options.externalAppOnly === true;
@@ -239,10 +259,11 @@ export default class I18nPlugin extends AdminForthPlugin {
239
259
  const compMeta = {
240
260
  brandSlug: adminforth.config.customization.brandNameSlug,
241
261
  pluginInstanceId: this.pluginInstanceId,
262
+ primaryLanguage: this.primaryLanguage,
242
263
  supportedLanguages: this.options.supportedLanguages.map(lang => ({
243
264
  code: lang,
244
265
  // lang name on on language native name
245
- name: iso6391.getNativeName(lang),
266
+ name: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
246
267
  }))
247
268
  };
248
269
  // add underLogin component
@@ -424,6 +445,7 @@ export default class I18nPlugin extends AdminForthPlugin {
424
445
  }
425
446
  translateToLang(langIsoCode_1, strings_1) {
426
447
  return __awaiter(this, arguments, void 0, function* (langIsoCode, strings, plurals = false, translations, updateStrings = {}) {
448
+ var _a;
427
449
  const maxKeysInOneReq = 10;
428
450
  if (strings.length === 0) {
429
451
  return [];
@@ -439,21 +461,23 @@ export default class I18nPlugin extends AdminForthPlugin {
439
461
  return totalTranslated;
440
462
  }
441
463
  const lang = langIsoCode;
442
- const langName = iso6391.getName(lang);
443
- const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(lang) && plurals;
464
+ const primaryLang = getPrimaryLanguageCode(lang);
465
+ const langName = iso6391.getName(primaryLang);
466
+ const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
467
+ const region = ((_a = String(lang).split('-')[1]) === null || _a === void 0 ? void 0 : _a.toUpperCase()) || '';
444
468
  const prompt = `
445
- I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
446
- ${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
447
- Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
469
+ I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
470
+ ${region ? `Use the regional conventions for ${lang} (region ${region}), including spelling, punctuation, and formatting.` : ''}
471
+ ${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
472
+ Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
448
473
 
449
- \`\`\`json
450
- ${JSON.stringify(strings.reduce((acc, s) => {
474
+ \`\`\`json
475
+ ${JSON.stringify(strings.reduce((acc, s) => {
451
476
  acc[s.en_string] = '';
452
477
  return acc;
453
478
  }, {}), null, 2)}
454
- \`\`\`
455
- `;
456
- process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM prompt >> ${prompt.length}, <<${prompt} :\n\n`, JSON.stringify(prompt));
479
+ \`\`\`
480
+ `;
457
481
  // call OpenAI
458
482
  const resp = yield this.options.completeAdapter.complete(prompt, [], prompt.length * 2);
459
483
  process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM resp >> ${prompt.length}, <<${resp.content.length} :\n\n`, JSON.stringify(resp));
@@ -657,11 +681,12 @@ ${JSON.stringify(strings.reduce((acc, s) => {
657
681
  throw new Error(`Category 'frontend' is reserved for frontend messages, use any other category for backend messages`);
658
682
  }
659
683
  // console.log('🪲tr', msg, category, lang);
660
- // if lang is not supported , throw
684
+ // if lang is not supported, fallback to primaryLanguage, then to english
661
685
  if (!this.options.supportedLanguages.includes(lang)) {
662
- lang = 'en'; // for now simply fallback to english
663
- // throwing like line below might be too strict, e.g. for custom apis made with fetch which don't pass accept-language
664
- // throw new Error(`Language ${lang} is not entered to be supported by requested by browser in request headers accept-language`);
686
+ lang = this.primaryLanguage; // fallback to primary language first
687
+ if (!this.options.supportedLanguages.includes(lang)) {
688
+ lang = 'en'; // final fallback to english
689
+ }
665
690
  }
666
691
  let result;
667
692
  // try to get translation from cache
@@ -753,7 +778,8 @@ ${JSON.stringify(strings.reduce((acc, s) => {
753
778
  const translations = {};
754
779
  const allTranslations = yield resource.list([Filters.EQ(this.options.categoryFieldName, category)]);
755
780
  for (const tr of allTranslations) {
756
- translations[tr[this.enFieldName]] = tr[this.trFieldNames[lang]];
781
+ const translatedValue = tr[this.trFieldNames[lang]];
782
+ translations[tr[this.enFieldName]] = translatedValue || tr[this.enFieldName];
757
783
  }
758
784
  yield this.cache.set(cacheKey, translations);
759
785
  return translations;
@@ -764,8 +790,8 @@ ${JSON.stringify(strings.reduce((acc, s) => {
764
790
  return this.options.supportedLanguages.map((lang) => {
765
791
  return {
766
792
  code: lang,
767
- nameOnNative: iso6391.getNativeName(lang),
768
- nameEnglish: iso6391.getName(lang),
793
+ nameOnNative: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
794
+ nameEnglish: iso6391.getName(getPrimaryLanguageCode(lang)),
769
795
  emojiFlag: getCountryCodeFromLangCode(lang).toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)),
770
796
  };
771
797
  });
package/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock } from "adminforth";
2
2
  import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, AdminForthConfigMenuItem } from "adminforth";
3
- import type { PluginOptions } from './types.js';
4
- import iso6391, { LanguageCode } from 'iso-639-1';
3
+ import type { PluginOptions, SupportedLanguage } from './types.js';
4
+ import iso6391 from 'iso-639-1';
5
+ import { iso31661Alpha2ToAlpha3 } from 'iso-3166';
5
6
  import path from 'path';
6
7
  import fs from 'fs-extra';
7
8
  import chokidar from 'chokidar';
@@ -35,10 +36,30 @@ const countryISO31661ByLangISO6391 = {
35
36
  ur: 'pk', // Urdu → Pakistan
36
37
  };
37
38
 
38
- function getCountryCodeFromLangCode(langCode) {
39
+ function getCountryCodeFromLangCode(lang: SupportedLanguage) {
40
+ const [langCode, region] = String(lang).split('-');
41
+ if (region && /^[A-Z]{2}$/.test(region)) {
42
+ return region.toLowerCase();
43
+ }
39
44
  return countryISO31661ByLangISO6391[langCode] || langCode;
40
45
  }
41
46
 
47
+ function getPrimaryLanguageCode(langCode: SupportedLanguage): string {
48
+ return String(langCode).split('-')[0];
49
+ }
50
+
51
+ function isValidSupportedLanguageTag(langCode: SupportedLanguage): boolean {
52
+ const [primary, region] = String(langCode).split('-');
53
+ if (!iso6391.validate(primary as any)) {
54
+ return false;
55
+ }
56
+ if (!region) {
57
+ return true;
58
+ }
59
+ const regionUpper = region.toUpperCase();
60
+ return /^[A-Z]{2}$/.test(regionUpper) && (regionUpper in iso31661Alpha2ToAlpha3);
61
+ }
62
+
42
63
 
43
64
  interface ICachingAdapter {
44
65
  get(key: string): Promise<any>;
@@ -84,7 +105,7 @@ export default class I18nPlugin extends AdminForthPlugin {
84
105
  passwordField: AdminForthResourceColumn;
85
106
  authResource: AdminForthResource;
86
107
  emailConfirmedField?: AdminForthResourceColumn;
87
- trFieldNames: Partial<Record<LanguageCode, string>>;
108
+ trFieldNames: Partial<Record<SupportedLanguage, string>>;
88
109
  enFieldName: string;
89
110
  cache: ICachingAdapter;
90
111
  primaryKeyFieldName: string;
@@ -96,16 +117,18 @@ export default class I18nPlugin extends AdminForthPlugin {
96
117
 
97
118
  // sorted by name list of all supported languages, without en e.g. 'al|ro|uk'
98
119
  fullCompleatedFieldValue: string;
120
+ primaryLanguage: SupportedLanguage;
99
121
 
100
122
  constructor(options: PluginOptions) {
101
123
  super(options, import.meta.url);
102
124
  this.options = options;
103
125
  this.cache = new CachingAdapterMemory();
104
126
  this.trFieldNames = {};
127
+ this.primaryLanguage = options.primaryLanguage || 'en';
105
128
  }
106
129
 
107
130
  async computeCompletedFieldValue(record: any) {
108
- return this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode): string => {
131
+ return this.options.supportedLanguages.reduce((acc: string, lang: SupportedLanguage): string => {
109
132
  if (lang === 'en') {
110
133
  return acc;
111
134
  }
@@ -134,10 +157,10 @@ export default class I18nPlugin extends AdminForthPlugin {
134
157
  async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
135
158
  super.modifyResourceConfig(adminforth, resourceConfig);
136
159
 
137
- // check each supported language is valid ISO 639-1 code
160
+ // validate each supported language: ISO 639-1 or BCP-47 with region (e.g., en-GB)
138
161
  this.options.supportedLanguages.forEach((lang) => {
139
- if (!iso6391.validate(lang)) {
140
- throw new Error(`Invalid language code ${lang}, please define valid ISO 639-1 language code (2 lowercase letters)`);
162
+ if (!isValidSupportedLanguageTag(lang)) {
163
+ throw new Error(`Invalid language code ${lang}. Use ISO 639-1 (e.g., 'en') or BCP-47 with region (e.g., 'en-GB').`);
141
164
  }
142
165
  });
143
166
 
@@ -191,7 +214,7 @@ export default class I18nPlugin extends AdminForthPlugin {
191
214
 
192
215
  this.enFieldName = this.trFieldNames['en'] || 'en_string';
193
216
 
194
- this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode) => {
217
+ this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: SupportedLanguage) => {
195
218
  if (lang === 'en') {
196
219
  return acc;
197
220
  }
@@ -269,11 +292,12 @@ export default class I18nPlugin extends AdminForthPlugin {
269
292
  const compMeta = {
270
293
  brandSlug: adminforth.config.customization.brandNameSlug,
271
294
  pluginInstanceId: this.pluginInstanceId,
295
+ primaryLanguage: this.primaryLanguage,
272
296
  supportedLanguages: this.options.supportedLanguages.map(lang => (
273
297
  {
274
298
  code: lang,
275
299
  // lang name on on language native name
276
- name: iso6391.getNativeName(lang),
300
+ name: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
277
301
  }
278
302
  ))
279
303
  };
@@ -317,7 +341,7 @@ export default class I18nPlugin extends AdminForthPlugin {
317
341
  resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord?: any }): Promise<{ ok: boolean, error?: string }> => {
318
342
  if (oldRecord) {
319
343
  // find lang which changed
320
- let langsChanged: LanguageCode[] = [];
344
+ let langsChanged: SupportedLanguage[] = [];
321
345
  for (const lang of this.options.supportedLanguages) {
322
346
  if (lang === 'en') {
323
347
  continue;
@@ -473,7 +497,7 @@ export default class I18nPlugin extends AdminForthPlugin {
473
497
  }
474
498
 
475
499
  async translateToLang (
476
- langIsoCode: LanguageCode,
500
+ langIsoCode: SupportedLanguage,
477
501
  strings: { en_string: string, category: string }[],
478
502
  plurals=false,
479
503
  translations: any[],
@@ -495,25 +519,25 @@ export default class I18nPlugin extends AdminForthPlugin {
495
519
  return totalTranslated;
496
520
  }
497
521
  const lang = langIsoCode;
498
- const langName = iso6391.getName(lang);
499
- const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(lang) && plurals;
500
-
522
+ const primaryLang = getPrimaryLanguageCode(lang);
523
+ const langName = iso6391.getName(primaryLang);
524
+ const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
525
+ const region = String(lang).split('-')[1]?.toUpperCase() || '';
501
526
  const prompt = `
502
- I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
503
- ${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
504
- Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
505
-
506
- \`\`\`json
507
- ${
508
- JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
509
- acc[s.en_string] = '';
510
- return acc;
511
- }, {}), null, 2)
512
- }
513
- \`\`\`
514
- `;
515
-
516
- process.env.HEAVY_DEBUG && console.log(`🪲🔪LLM prompt >> ${prompt.length}, <<${prompt} :\n\n`, JSON.stringify(prompt));
527
+ I need to translate strings in JSON to ${langName} language (ISO 639-1 code ${lang}) from English for my web app.
528
+ ${region ? `Use the regional conventions for ${lang} (region ${region}), including spelling, punctuation, and formatting.` : ''}
529
+ ${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
530
+ Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
531
+
532
+ \`\`\`json
533
+ ${
534
+ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
535
+ acc[s.en_string] = '';
536
+ return acc;
537
+ }, {}), null, 2)
538
+ }
539
+ \`\`\`
540
+ `;
517
541
 
518
542
  // call OpenAI
519
543
  const resp = await this.options.completeAdapter.complete(
@@ -584,7 +608,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
584
608
 
585
609
  const needToTranslateByLang : Partial<
586
610
  Record<
587
- LanguageCode,
611
+ SupportedLanguage,
588
612
  {
589
613
  en_string: string;
590
614
  category: string;
@@ -627,7 +651,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
627
651
 
628
652
  await Promise.all(
629
653
  Object.entries(needToTranslateByLang).map(
630
- async ([lang, strings]: [LanguageCode, { en_string: string, category: string }[]]) => {
654
+ async ([lang, strings]: [SupportedLanguage, { en_string: string, category: string }[]]) => {
631
655
  // first translate without plurals
632
656
  const stringsWithoutPlurals = strings.filter(s => !s.en_string.includes('|'));
633
657
  const noPluralKeys = await this.translateToLang(lang, stringsWithoutPlurals, false, translations, updateStrings);
@@ -781,13 +805,13 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
781
805
  }
782
806
  // console.log('🪲tr', msg, category, lang);
783
807
 
784
- // if lang is not supported , throw
785
- if (!this.options.supportedLanguages.includes(lang as LanguageCode)) {
786
- lang = 'en'; // for now simply fallback to english
787
-
788
- // throwing like line below might be too strict, e.g. for custom apis made with fetch which don't pass accept-language
789
- // throw new Error(`Language ${lang} is not entered to be supported by requested by browser in request headers accept-language`);
808
+ // if lang is not supported, fallback to primaryLanguage, then to english
809
+ if (!this.options.supportedLanguages.includes(lang as SupportedLanguage)) {
810
+ lang = this.primaryLanguage; // fallback to primary language first
811
+ if (!this.options.supportedLanguages.includes(lang as SupportedLanguage)) {
812
+ lang = 'en'; // final fallback to english
790
813
  }
814
+ }
791
815
 
792
816
  let result;
793
817
  // try to get translation from cache
@@ -885,23 +909,24 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
885
909
  const translations = {};
886
910
  const allTranslations = await resource.list([Filters.EQ(this.options.categoryFieldName, category)]);
887
911
  for (const tr of allTranslations) {
888
- translations[tr[this.enFieldName]] = tr[this.trFieldNames[lang]];
912
+ const translatedValue = tr[this.trFieldNames[lang]];
913
+ translations[tr[this.enFieldName]] = translatedValue || tr[this.enFieldName];
889
914
  }
890
915
  await this.cache.set(cacheKey, translations);
891
916
  return translations;
892
917
  }
893
918
 
894
919
  async languagesList(): Promise<{
895
- code: LanguageCode;
920
+ code: SupportedLanguage;
896
921
  nameOnNative: string;
897
922
  nameEnglish: string;
898
923
  emojiFlag: string;
899
924
  }[]> {
900
925
  return this.options.supportedLanguages.map((lang) => {
901
926
  return {
902
- code: lang as LanguageCode,
903
- nameOnNative: iso6391.getNativeName(lang),
904
- nameEnglish: iso6391.getName(lang),
927
+ code: lang,
928
+ nameOnNative: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
929
+ nameEnglish: iso6391.getName(getPrimaryLanguageCode(lang)),
905
930
  emojiFlag: getCountryCodeFromLangCode(lang).toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)),
906
931
  };
907
932
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/i18n",
3
- "version": "1.3.7",
3
+ "version": "1.4.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -28,6 +28,8 @@
28
28
  "@aws-sdk/client-ses": "^3.654.0",
29
29
  "@sapphire/async-queue": "^1.5.5",
30
30
  "chokidar": "^4.0.1",
31
+ "fs-extra": "^11.3.2",
32
+ "iso-3166": "^4.3.0",
31
33
  "iso-639-1": "^3.1.3"
32
34
  },
33
35
  "devDependencies": {
package/types.ts CHANGED
@@ -1,17 +1,23 @@
1
1
  import { CompletionAdapter, EmailAdapter } from 'adminforth';
2
2
  import type { LanguageCode } from 'iso-639-1';
3
+ import { iso31661Alpha2ToAlpha3 } from 'iso-3166';
3
4
 
4
5
 
6
+ // BCP-47 support for types only: primary subtag is ISO 639-1, optional region
7
+ type Alpha2Code = keyof typeof iso31661Alpha2ToAlpha3;
8
+ type Bcp47LanguageTag = `${LanguageCode}-${Alpha2Code}`;
9
+ export type SupportedLanguage = LanguageCode | Bcp47LanguageTag;
10
+
5
11
  export interface PluginOptions {
6
12
 
7
13
  /* List of ISO 639-1 language codes which you want to tsupport*/
8
- supportedLanguages: LanguageCode[];
14
+ supportedLanguages: SupportedLanguage[];
9
15
 
10
16
  /**
11
17
  * Each translation string will be stored in a separate field, you can remap it to existing columns using this option
12
18
  * By default it will assume field are named like `${lang_code}_string` (e.g. 'en_string', 'uk_string', 'ja_string', 'fr_string')
13
19
  */
14
- translationFieldNames: Partial<Record<LanguageCode, string>>;
20
+ translationFieldNames: Partial<Record<SupportedLanguage, string>>;
15
21
 
16
22
  /**
17
23
  * Each string has a category, e.g. it might come from 'frontend' or some message from backend or column name on backend
@@ -49,4 +55,12 @@ export interface PluginOptions {
49
55
  * it should be a JSON field (underlyng database type should be TEXT or JSON)
50
56
  */
51
57
  reviewedCheckboxesFieldName?: string;
58
+
59
+ /**
60
+ * Primary language for the application. This is the default language shown to users.
61
+ * English is always used as the source language for translations, even if primaryLanguage is different.
62
+ * When a translation is missing for the primaryLanguage, English will be shown as fallback.
63
+ * Defaults to 'en' if not specified.
64
+ */
65
+ primaryLanguage?: SupportedLanguage;
52
66
  }