11import AdminForth , { AdminForthPlugin , Filters , suggestIfTypo , AdminForthDataTypes , RAMLock } from "adminforth" ;
22import 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' ;
56import path from 'path' ;
67import fs from 'fs-extra' ;
78import 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
4364interface 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