File

lib/common/src/translations/nong/translators/string.translator.ts

Index

Properties

Properties

scope
scope: string | null
Type : string | null
translationMap
translationMap: literal type
Type : literal type
import	{	
			deflateObject,
			inflateObject,	
			NestedRecord,
			mergeObjects,
			isNestedRecord,
			assert,
			camel2Underscore
		}								from '@rcc/core'

import	{ 	Translator 				} 	from '../translator.class'

import	{ 	
			TranslationResult,
			isFinal		
		}								from '../interfaces'




// ### Interfaces and ultilities:


export type TranslationMap  = NestedRecord<string>

export function isTranslationMap(x:unknown): x is TranslationMap {
	return isNestedRecord(x, 'string')
}


export interface ScopedTranslationMap {
	scope: 			string | null
	translationMap:	{ [lang: string] : TranslationMap }
}


export type TranslationKey 		= string & { __isTranslationKey?: true}
export type TranslationTemplate	= string & { __isTranslationTemplate?: true}




export function isTranslationKey(key: string): key is TranslationKey {

	if(typeof key != 'string') 		return false

	if(key == '')					return false

	if(key.match(/^[A-Z0-9_.-]+$/))	return true

	return false
}



export function isTranslationTemplate(str: string): str is TranslationTemplate {
	
	if(typeof str != 'string') 		return false

	if(str == '')					return false	

	if(str.match(/{{[^{}]+}}/))		return true

	return false
}


/**
 * Strips everything, that is not a capital letter, underscore, dot,  a digit or a curley bracket, off a given string.
 * If the result would be an empty string (thus no translation key) '_EMPTY_KEY' is returned instead and a warning logged.
 **/
export function normalizeTranslationKey(str:string) : TranslationKey {

	const normalized_str = camel2Underscore(str).toUpperCase().replace(/[^A-Z0-9_.]/g,"")

	if(isTranslationKey(normalized_str)) return normalized_str

	console.warn(`normalizeTranslationKey() results in empty key for '${str}'.`)	

	return 	'_EMPTY_KEY'
}




/**
 * This function is meant for basic replacements in translation strings. 
 * Occurences of %_n_ , with _n_ being some integer, will be replaced by the _n_-th paramter, starting at 1. 
 * Occurences of %_s_ with _s_ being a single lowercase letter, will be replaced by the parameters in the order of their appearance; 
 * same lower case letters will be replaced by different values. 
 * The replacement text will only include capital letters, digits, dots and underscores, everything else will be stripped off:
 * ```
 * 	extendTranslationKey('SOME_SCOPE.%s' | fill: paused ? 'player_running':'player_paused') == 'SOME_SCOPE.PLAYER_PAUSED' //true iff pause is truthy.
 * 
 *	extendTranslationKey('SOME_SCOPE.GROUP_%2.LABEL_%1' | fill :  'long', 1) == 'SOME_SCOPE.GROUP_1.LABEL_LONG' //true
 * 
 * 	extendTranslationKey('SOME_SCOPE.GROUP_%s.LABEL_%t' | fill :  'long', 1) == 'SOME_SCOPE.GROUP_LONG.LABEL_1' //true
 * 
 * ```
 **/

export function extendTranslationKey(key: TranslationKey, ...content: (string|number)[] ): TranslationKey {

	if(!key) return ''

	let pos = 0

	key = key.replace(/%[a-z]+/g, () => String(content[pos++] || '') )

	content.forEach( (item, i) => {
		key = key.replace(new RegExp(`%${i+1}`,'g'), String(item))
	})

	return normalizeTranslationKey(key)
}








// ### Translator class:




/**
 * Replaces translation keys with prior provided translations for a given language.
 **/

export class StringTranslator extends Translator {


	translationMaps : { [index:string] : TranslationMap } = {}


	constructor(
		scopedTranslationMaps : ScopedTranslationMap[],
	){
		super()



		// Without any translation maps this translator cannot do anything.
		assert(scopedTranslationMaps, "StringTranslator.constructor(): missing scopedTranslationsMaps.")


		// Setup the central dictionary for all string translations:
		scopedTranslationMaps.forEach( scopedTranslationMap => {

			const { scope, translationMap } = scopedTranslationMap						

			for(const language in translationMap ){ 
				this.addTranslation(translationMap[language], language, scope) 
			} 

		})

	}

	get compatibleLanguages() : string[] {

		const languages = Object.keys(this.translationMaps)
		

		return 	languages.length == 0
				// This can happens if no translationMap was ever provided,
				// so nothing needs to be translated and this translator is
				// compatible with all langauges. -.OO.-
				?	null
				:	languages

	}

	match(x: unknown): number {

		if(typeof x != 'string') 		return -1
		if(x == '')						return -1


		if(isTranslationKey(x)) 		return 1
		if(isTranslationTemplate(x))	return 1

		return	0

	}

	/**
	 * Looks up the entry for key and language in the central dictionary and returns it.
	 * A translation may have replaceable substrings. Those substrings can be a property name 
	 * `{{somePropertyName}}` or another translation key `{{SOME_SCOPE.MY_KEY}}`.
	 * If such a substring matches a property of param, the substring will be replaced by that property.
	 * If a substring matches a translation key, the substring will be replaced by the respective translation.
	 * Params take prescedence.
	 * 
	 * ```js
	 * 	let stringTranslator = new StringTranslator()
	 * 
	 * 	stringTranslator.translate('SOME_SCOPE.GREETING', 'en') 					
	 *	// May give something like 'Hello {{address}}!'
	 * 
	 * 	stringTranslator.translate('SOME_SCOPE.GREETING', 'en', {address:'world'}) 	
	 *	// Will then give 'Hello world!'
	 *	
	 * 
	 * ```
	 **/
	translate(input: string, language: string, param?: Record<string, unknown>, recursionCount = 0): TranslationResult {

		//recursive

		// If we can't handle the input with this translator:
		if(this.match(input) < 0) return null

		
		//TODO: maybe look for a way not to call match on every recursion call

		if(recursionCount > 20) {
			console.warn('StringTranslator.translate() recursionCount greater than 20; stopping recursion: '+input)
			return null
		}

		// Check if input is a translation key, that is registered in the central dictionary:
		if(isTranslationKey(input)) {

			const preliminary_translation = this.deflateMap(input, this.translationMaps[language])

			if(typeof preliminary_translation != 'string') return null //no matching translation found

			return this.translate(preliminary_translation, language, param, recursionCount++) //maybe the preliminary translation includes new translation keys...

		}


		// Check if the string is a translation template. (if so it probably comes from a recursion step)
		if(isTranslationTemplate(input)){

			const translation  =	input.replace(

										/{{([^}]+)}}/g, 

										(match: string, sub_match: string) =>	{

											sub_match 				= 	sub_match.trim()

											const param_value 		= 	deflateObject(param, sub_match)

											const sub_value 		= 	param_value !== undefined
																		?	String(param_value)	// use params to fill in variables
																		:	sub_match			// use dictionary to fill in variables
																	
											const sub_translation	= 	this.translate(sub_value, language, param, recursionCount++)

											return	isFinal(sub_translation)
													?	sub_translation.final
													:	sub_value								
										}
									)

			return	{ final: translation }
					

		}

		return { final: input }
	}


	/**
	 * Add new translations to the central dictionary for a specific language.
	 * Specifiying a scope will prepend the scope to all translation keys.
	 **/
	addTranslation(translationMap : TranslationMap, language: string, scope : string = '') : void {

		if(!scope && typeof translationMap == 'string'){
			console.warn('StringTranslator.addTranslation() scope must not be empty, if translationMap is a string;', translationMap, language, scope)
			return;
		}

		const root_table	= 	scope
								?	this.inflateMap(scope, translationMap)
								:	translationMap

		const mergedTable 	=	mergeObjects(this.translationMaps[language] || {}, root_table)

		this.translationMaps[language] = mergedTable
	}

	
	/**
	 * Splits the key into its parts and feeds them recursively into the
	 * tanslationMap returning a sub map, a translation or null if no matching sub map or translation exist.
	 * ```js
	 * 	let key = 'SOME_SCOPE.SECTION.MY_KEY'	
	 * 	let stringTranslator = new StringTranslator()
	 * 		
	 * 
	 * 	stringTranslator.deflateMap(key, translationMap) 
	 *	// yields: translationMap['SOME_SCOPE']['SECTION']['MY_KEY']
	 * ```
	 **/
	deflateMap(key: TranslationKey, translationMap: TranslationMap): string | TranslationMap | null {
		
		return deflateObject(translationMap, key) || null

	}

	/**
	 * Splits the key into its parts and recursively prepends them to the given translation map or string.
	 * 
	 * ```js
	 * 	let key = 'SOME_SCOPE.SECTION.MY_KEY'	
	 * 	
	 * 	stringTranslator.inflateMap(key, 'Hello World!') 
	 * 	// yields: { 'SOME_SCOPE': {'SECTION': { 'MY_KEY': 'Hello World"'} } } }
	 * 
	 * 	stringTranslator.inflateMap(key, {'GREETING':Hello World!'} ) 
	 * 	// yields: { 'SOME_SCOPE': {'SECTION': { 'MY_KEY': {'GREETING':Hello World!'}} } } }
	 * ```
	 **/

	inflateMap(key: TranslationKey, value: string | TranslationMap): TranslationMap {
		
		return 	inflateObject(value, key)
				
	}


}

results matching ""

    No results matching ""