Skip to content

Commit 9314cba

Browse files
authored
Merge pull request #7 from devforth/bcp47-support
Bcp47 support
2 parents 6b3b9e8 + ad2315f commit 9314cba

8 files changed

Lines changed: 757 additions & 975 deletions

File tree

custom/LanguageEveryPageLoader.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
<script setup>
44
import { onMounted } from 'vue';
55
import { useI18n } from 'vue-i18n';
6-
import { setLang, getLocalLang } from './langCommon';
6+
import { setLang, getLocalLang, setLocalLang } from './langCommon';
77
88
const { setLocaleMessage, locale } = useI18n();
99
1010
const props = defineProps(['meta']);
1111
1212
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);
1416
});
1517
</script>

custom/LanguageInUserMenu.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ function doChangeLang(lang) {
7070
7171
const options = computed(() => {
7272
return props.meta.supportedLanguages.map((lang) => {
73+
const region = String(lang.code).split('-')[1]?.toUpperCase();
7374
return {
7475
value: lang.code,
75-
label: lang.name,
76+
label: region ? `${lang.name} (${region})` : lang.name,
7677
};
7778
});
7879
});
@@ -87,6 +88,6 @@ const selectedOption = computed(() => {
8788
8889
8990
onMounted(() => {
90-
selectedLanguage.value = getLocalLang(props.meta.supportedLanguages);
91+
selectedLanguage.value = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
9192
});
9293
</script>

custom/LanguageUnderLogin.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,15 @@ watch(() => selectedLanguage.value, async (newVal) => {
5353
5454
const options = computed(() => {
5555
return props.meta.supportedLanguages.map((lang) => {
56+
const region = String(lang.code).split('-')[1]?.toUpperCase();
5657
return {
5758
value: lang.code,
58-
label: lang.name,
59+
label: region ? `${lang.name} (${region})` : lang.name,
5960
};
6061
});
6162
});
6263
6364
onMounted(() => {
64-
selectedLanguage.value = getLocalLang(props.meta.supportedLanguages);
65+
selectedLanguage.value = getLocalLang(props.meta.supportedLanguages, props.meta.primaryLanguage);
6566
});
6667
</script>

custom/langCommon.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import dayjsLocales from './dayjsLocales';
44
import datepickerLocales from './datepickerLocales';
55
import dayjs from 'dayjs';
66
import Datepicker from "flowbite-datepicker/Datepicker";
7+
import type { SupportedLanguage } from '../types';
78

89

910
const messagesCache: Record<
@@ -78,13 +79,17 @@ const countryISO31661ByLangISO6391 = {
7879
};
7980

8081
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;
8287
}
8388

8489

8590
const LS_LANG_KEY = `afLanguage`;
8691

87-
export function getLocalLang(supportedLanguages: {code}[]): string {
92+
export function getLocalLang(supportedLanguages: {code}[], primaryLanguage?: SupportedLanguage): string {
8893
let lsLang = localStorage.getItem(LS_LANG_KEY);
8994
// if someone screwed up the local storage or we stopped language support, lets check if it is in supported languages
9095
if (lsLang && !supportedLanguages.find((l) => l.code == lsLang)) {
@@ -99,6 +104,10 @@ export function getLocalLang(supportedLanguages: {code}[]): string {
99104
if (foundLang) {
100105
return foundLang.code;
101106
}
107+
if (primaryLanguage && supportedLanguages.find((l) => l.code == primaryLanguage)) {
108+
return primaryLanguage;
109+
}
110+
102111
return supportedLanguages[0].code;
103112
}
104113

index.ts

Lines changed: 68 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock } from "adminforth";
22
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';
56
import path from 'path';
67
import fs from 'fs-extra';
78
import chokidar from 'chokidar';
@@ -35,10 +36,30 @@ const countryISO31661ByLangISO6391 = {
3536
ur: 'pk', // Urdu → Pakistan
3637
};
3738

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+
}
3944
return countryISO31661ByLangISO6391[langCode] || langCode;
4045
}
4146

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+
4263

4364
interface ICachingAdapter {
4465
get(key: string): Promise<any>;
@@ -84,7 +105,7 @@ export default class I18nPlugin extends AdminForthPlugin {
84105
passwordField: AdminForthResourceColumn;
85106
authResource: AdminForthResource;
86107
emailConfirmedField?: AdminForthResourceColumn;
87-
trFieldNames: Partial<Record<LanguageCode, string>>;
108+
trFieldNames: Partial<Record<SupportedLanguage, string>>;
88109
enFieldName: string;
89110
cache: ICachingAdapter;
90111
primaryKeyFieldName: string;
@@ -96,16 +117,18 @@ export default class I18nPlugin extends AdminForthPlugin {
96117

97118
// sorted by name list of all supported languages, without en e.g. 'al|ro|uk'
98119
fullCompleatedFieldValue: string;
120+
primaryLanguage: SupportedLanguage;
99121

100122
constructor(options: PluginOptions) {
101123
super(options, import.meta.url);
102124
this.options = options;
103125
this.cache = new CachingAdapterMemory();
104126
this.trFieldNames = {};
127+
this.primaryLanguage = options.primaryLanguage || 'en';
105128
}
106129

107130
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 => {
109132
if (lang === 'en') {
110133
return acc;
111134
}
@@ -134,10 +157,10 @@ export default class I18nPlugin extends AdminForthPlugin {
134157
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
135158
super.modifyResourceConfig(adminforth, resourceConfig);
136159

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)
138161
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').`);
141164
}
142165
});
143166

@@ -191,7 +214,7 @@ export default class I18nPlugin extends AdminForthPlugin {
191214

192215
this.enFieldName = this.trFieldNames['en'] || 'en_string';
193216

194-
this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode) => {
217+
this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: SupportedLanguage) => {
195218
if (lang === 'en') {
196219
return acc;
197220
}
@@ -269,11 +292,12 @@ export default class I18nPlugin extends AdminForthPlugin {
269292
const compMeta = {
270293
brandSlug: adminforth.config.customization.brandNameSlug,
271294
pluginInstanceId: this.pluginInstanceId,
295+
primaryLanguage: this.primaryLanguage,
272296
supportedLanguages: this.options.supportedLanguages.map(lang => (
273297
{
274298
code: lang,
275299
// lang name on on language native name
276-
name: iso6391.getNativeName(lang),
300+
name: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
277301
}
278302
))
279303
};
@@ -317,7 +341,7 @@ export default class I18nPlugin extends AdminForthPlugin {
317341
resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord?: any }): Promise<{ ok: boolean, error?: string }> => {
318342
if (oldRecord) {
319343
// find lang which changed
320-
let langsChanged: LanguageCode[] = [];
344+
let langsChanged: SupportedLanguage[] = [];
321345
for (const lang of this.options.supportedLanguages) {
322346
if (lang === 'en') {
323347
continue;
@@ -473,7 +497,7 @@ export default class I18nPlugin extends AdminForthPlugin {
473497
}
474498

475499
async translateToLang (
476-
langIsoCode: LanguageCode,
500+
langIsoCode: SupportedLanguage,
477501
strings: { en_string: string, category: string }[],
478502
plurals=false,
479503
translations: any[],
@@ -495,25 +519,25 @@ export default class I18nPlugin extends AdminForthPlugin {
495519
return totalTranslated;
496520
}
497521
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() || '';
501526
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+
`;
517541

518542
// call OpenAI
519543
const resp = await this.options.completeAdapter.complete(
@@ -584,7 +608,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
584608

585609
const needToTranslateByLang : Partial<
586610
Record<
587-
LanguageCode,
611+
SupportedLanguage,
588612
{
589613
en_string: string;
590614
category: string;
@@ -627,7 +651,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
627651

628652
await Promise.all(
629653
Object.entries(needToTranslateByLang).map(
630-
async ([lang, strings]: [LanguageCode, { en_string: string, category: string }[]]) => {
654+
async ([lang, strings]: [SupportedLanguage, { en_string: string, category: string }[]]) => {
631655
// first translate without plurals
632656
const stringsWithoutPlurals = strings.filter(s => !s.en_string.includes('|'));
633657
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 =>
781805
}
782806
// console.log('🪲tr', msg, category, lang);
783807

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
790813
}
814+
}
791815

792816
let result;
793817
// try to get translation from cache
@@ -885,23 +909,24 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
885909
const translations = {};
886910
const allTranslations = await resource.list([Filters.EQ(this.options.categoryFieldName, category)]);
887911
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];
889914
}
890915
await this.cache.set(cacheKey, translations);
891916
return translations;
892917
}
893918

894919
async languagesList(): Promise<{
895-
code: LanguageCode;
920+
code: SupportedLanguage;
896921
nameOnNative: string;
897922
nameEnglish: string;
898923
emojiFlag: string;
899924
}[]> {
900925
return this.options.supportedLanguages.map((lang) => {
901926
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)),
905930
emojiFlag: getCountryCodeFromLangCode(lang).toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)),
906931
};
907932
});

0 commit comments

Comments
 (0)