Skip to content

Commit 45d3873

Browse files
committed
feat: add BCP-47 tags support
1 parent 9a1fe9d commit 45d3873

7 files changed

Lines changed: 132 additions & 56 deletions

File tree

custom/LanguageInUserMenu.vue

Lines changed: 2 additions & 8 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
});
@@ -92,11 +93,4 @@ onMounted(() => {
9293
setLang({ setLocaleMessage, locale }, props.meta.pluginInstanceId, selectedLanguage.value);
9394
// todo this mounted executed only on this component mount, f5 from another page apart login will not read it
9495
});
95-
96-
97-
98-
99-
100-
101-
10296
</script>

custom/LanguageUnderLogin.vue

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ 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
});
@@ -67,10 +68,4 @@ onMounted(() => {
6768
// todo this mounted executed only on this component mount, f5 from another page apart login will not read it
6869
});
6970
70-
71-
72-
73-
74-
75-
7671
</script>

custom/langCommon.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ const countryISO31661ByLangISO6391 = {
5858
};
5959

6060
export function getCountryCodeFromLangCode(langCode) {
61-
return countryISO31661ByLangISO6391[langCode] || langCode;
61+
const [primary, region] = String(langCode).split('-');
62+
if (region && /^[A-Za-z]{2}$/.test(region)) {
63+
return region.toLowerCase();
64+
}
65+
return countryISO31661ByLangISO6391[primary] || primary;
6266
}
6367

6468

index.ts

Lines changed: 60 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes } from "adminforth";
1+
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes } from "../../adminforth/dist/index.js";
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';
@@ -37,10 +38,30 @@ const countryISO31661ByLangISO6391 = {
3738
ur: 'pk', // Urdu → Pakistan
3839
};
3940

40-
function getCountryCodeFromLangCode(langCode) {
41+
function getCountryCodeFromLangCode(lang: SupportedLanguage) {
42+
const [langCode, region] = String(lang).split('-');
43+
if (region && /^[A-Z]{2}$/.test(region)) {
44+
return region.toLowerCase();
45+
}
4146
return countryISO31661ByLangISO6391[langCode] || langCode;
4247
}
4348

49+
function getPrimaryLanguageCode(langCode: SupportedLanguage): string {
50+
return String(langCode).split('-')[0];
51+
}
52+
53+
function isValidSupportedLanguageTag(langCode: SupportedLanguage): boolean {
54+
const [primary, region] = String(langCode).split('-');
55+
if (!iso6391.validate(primary as any)) {
56+
return false;
57+
}
58+
if (!region) {
59+
return true;
60+
}
61+
const regionUpper = region.toUpperCase();
62+
return /^[A-Z]{2}$/.test(regionUpper) && (regionUpper in iso31661Alpha2ToAlpha3);
63+
}
64+
4465

4566
interface ICachingAdapter {
4667
get(key: string): Promise<any>;
@@ -86,7 +107,7 @@ export default class I18nPlugin extends AdminForthPlugin {
86107
passwordField: AdminForthResourceColumn;
87108
authResource: AdminForthResource;
88109
emailConfirmedField?: AdminForthResourceColumn;
89-
trFieldNames: Partial<Record<LanguageCode, string>>;
110+
trFieldNames: Partial<Record<SupportedLanguage, string>>;
90111
enFieldName: string;
91112
cache: ICachingAdapter;
92113
primaryKeyFieldName: string;
@@ -105,7 +126,7 @@ export default class I18nPlugin extends AdminForthPlugin {
105126
}
106127

107128
async computeCompletedFieldValue(record: any) {
108-
return this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode): string => {
129+
return this.options.supportedLanguages.reduce((acc: string, lang: SupportedLanguage): string => {
109130
if (lang === 'en') {
110131
return acc;
111132
}
@@ -134,10 +155,10 @@ export default class I18nPlugin extends AdminForthPlugin {
134155
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
135156
super.modifyResourceConfig(adminforth, resourceConfig);
136157

137-
// check each supported language is valid ISO 639-1 code
158+
// validate each supported language: ISO 639-1 or BCP-47 with region (e.g., en-GB)
138159
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)`);
160+
if (!isValidSupportedLanguageTag(lang)) {
161+
throw new Error(`Invalid language code ${lang}. Use ISO 639-1 (e.g., 'en') or BCP-47 with region (e.g., 'en-GB').`);
141162
}
142163
});
143164

@@ -169,7 +190,7 @@ export default class I18nPlugin extends AdminForthPlugin {
169190

170191
this.enFieldName = this.trFieldNames['en'] || 'en_string';
171192

172-
this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: LanguageCode) => {
193+
this.fullCompleatedFieldValue = this.options.supportedLanguages.reduce((acc: string, lang: SupportedLanguage) => {
173194
if (lang === 'en') {
174195
return acc;
175196
}
@@ -223,7 +244,7 @@ export default class I18nPlugin extends AdminForthPlugin {
223244
{
224245
code: lang,
225246
// lang name on on language native name
226-
name: iso6391.getNativeName(lang),
247+
name: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
227248
}
228249
))
229250
};
@@ -260,7 +281,7 @@ export default class I18nPlugin extends AdminForthPlugin {
260281
resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord?: any }): Promise<{ ok: boolean, error?: string }> => {
261282
if (oldRecord) {
262283
// find lang which changed
263-
let langsChanged: LanguageCode[] = [];
284+
let langsChanged: SupportedLanguage[] = [];
264285
for (const lang of this.options.supportedLanguages) {
265286
if (lang === 'en') {
266287
continue;
@@ -362,7 +383,7 @@ export default class I18nPlugin extends AdminForthPlugin {
362383
icon: 'flowbite:language-outline',
363384
// if optional `confirm` is provided, user will be asked to confirm action
364385
confirm: 'Are you sure you want to translate selected items?',
365-
state: 'selected',
386+
state: 'selected' as any,
366387
allowed: async ({ resource, adminUser, selectedIds, allowedActions }) => {
367388
console.log('allowedActions', JSON.stringify(allowedActions));
368389
return allowedActions.edit;
@@ -416,7 +437,7 @@ export default class I18nPlugin extends AdminForthPlugin {
416437
}
417438

418439
async translateToLang (
419-
langIsoCode: LanguageCode,
440+
langIsoCode: SupportedLanguage,
420441
strings: { en_string: string, category: string }[],
421442
plurals=false,
422443
translations: any[],
@@ -438,24 +459,25 @@ export default class I18nPlugin extends AdminForthPlugin {
438459
return totalTranslated;
439460
}
440461
const lang = langIsoCode;
441-
const langName = iso6391.getName(lang);
442-
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(lang) && plurals;
443-
462+
const primaryLang = getPrimaryLanguageCode(lang);
463+
const langName = iso6391.getName(primaryLang);
464+
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
465+
const region = String(lang).split('-')[1]?.toUpperCase() || '';
444466
const prompt = `
445-
I need to translate strings in JSON to ${lang} (${langName}) language 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! Here are the strings:
448-
449-
\`\`\`json
450-
${
451-
JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
452-
acc[s.en_string] = '';
453-
return acc;
454-
}, {}), null, 2)
455-
}
456-
\`\`\`
457-
`;
458-
467+
I need to translate strings in JSON to ${lang} (${langName}) language from English for my web app.
468+
${region ? `Use the regional conventions for ${lang} (region ${region}), including spelling, punctuation, and formatting.` : ''}
469+
${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[primaryLang]}"` : ''}
470+
Keep keys, as is, write translation into values! Here are the strings:
471+
472+
\`\`\`json
473+
${
474+
JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
475+
acc[s.en_string] = '';
476+
return acc;
477+
}, {}), null, 2)
478+
}
479+
\`\`\`
480+
`;
459481
// call OpenAI
460482
const resp = await this.options.completeAdapter.complete(
461483
prompt,
@@ -524,7 +546,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
524546

525547
const needToTranslateByLang : Partial<
526548
Record<
527-
LanguageCode,
549+
SupportedLanguage,
528550
{
529551
en_string: string;
530552
category: string;
@@ -567,7 +589,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
567589

568590
await Promise.all(
569591
Object.entries(needToTranslateByLang).map(
570-
async ([lang, strings]: [LanguageCode, { en_string: string, category: string }[]]) => {
592+
async ([lang, strings]: [SupportedLanguage, { en_string: string, category: string }[]]) => {
571593
// first translate without plurals
572594
const stringsWithoutPlurals = strings.filter(s => !s.en_string.includes('|'));
573595
const noPluralKeys = await this.translateToLang(lang, stringsWithoutPlurals, false, translations, updateStrings);
@@ -706,7 +728,7 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
706728
// console.log('🪲tr', msg, category, lang);
707729

708730
// if lang is not supported , throw
709-
if (!this.options.supportedLanguages.includes(lang as LanguageCode)) {
731+
if (!this.options.supportedLanguages.includes(lang as SupportedLanguage)) {
710732
lang = 'en'; // for now simply fallback to english
711733

712734
// throwing like line below might be too strict, e.g. for custom apis made with fetch which don't pass accept-language
@@ -813,16 +835,16 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
813835
}
814836

815837
async languagesList(): Promise<{
816-
code: LanguageCode;
838+
code: SupportedLanguage;
817839
nameOnNative: string;
818840
nameEnglish: string;
819841
emojiFlag: string;
820842
}[]> {
821843
return this.options.supportedLanguages.map((lang) => {
822844
return {
823-
code: lang as LanguageCode,
824-
nameOnNative: iso6391.getNativeName(lang),
825-
nameEnglish: iso6391.getName(lang),
845+
code: lang,
846+
nameOnNative: iso6391.getNativeName(getPrimaryLanguageCode(lang)),
847+
nameEnglish: iso6391.getName(getPrimaryLanguageCode(lang)),
826848
emojiFlag: getCountryCodeFromLangCode(lang).toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)),
827849
};
828850
});

package-lock.json

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

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"@aws-sdk/client-ses": "^3.654.0",
1919
"@sapphire/async-queue": "^1.5.5",
2020
"chokidar": "^4.0.1",
21+
"fs-extra": "^11.3.2",
22+
"iso-3166": "^4.3.0",
2123
"iso-639-1": "^3.1.3"
2224
},
2325
"devDependencies": {

types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import { CompletionAdapter, EmailAdapter } from 'adminforth';
22
import type { LanguageCode } from 'iso-639-1';
3+
import { iso31661Alpha2ToAlpha3 } from 'iso-3166';
34

45

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+
511
export interface PluginOptions {
612

713
/* List of ISO 639-1 language codes which you want to tsupport*/
8-
supportedLanguages: LanguageCode[];
14+
supportedLanguages: SupportedLanguage[];
915

1016
/**
1117
* Each translation string will be stored in a separate field, you can remap it to existing columns using this option
1218
* By default it will assume field are named like `${lang_code}_string` (e.g. 'en_string', 'uk_string', 'ja_string', 'fr_string')
1319
*/
14-
translationFieldNames: Partial<Record<LanguageCode, string>>;
20+
translationFieldNames: Partial<Record<SupportedLanguage, string>>;
1521

1622
/**
1723
* Each string has a category, e.g. it might come from 'frontend' or some message from backend or column name on backend

0 commit comments

Comments
 (0)