From 3319e609c4646c61c08fde293908109b8a9d241c Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 13 Nov 2025 01:11:56 +0000 Subject: [PATCH 1/4] fix: setCurrent pulling double duty and loading languages when it shouldnt --- src/mi18n.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mi18n.js b/src/mi18n.js index b22b982..6fe3c00 100644 --- a/src/mi18n.js +++ b/src/mi18n.js @@ -61,7 +61,8 @@ export class I18N { addLanguage(locale, lang = {}) { lang = typeof lang === 'string' ? I18N.processFile(lang) : lang this.applyLanguage(locale, lang) - this.config.langs.push('locale') + this.loaded.push(locale) + this.config.langs.push(locale) } /** @@ -229,7 +230,10 @@ export class I18N { * @return {Promise} language */ async setCurrent(locale = 'en-US') { - await this.loadLang(locale) + if (!this.loaded.includes(locale)) { + await this.loadLang(locale) + } + this.locale = locale this.current = this.langs[locale] From 639a37195f77461522ad554cac812da99d6d6eda Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 13 Nov 2025 01:39:00 +0000 Subject: [PATCH 2/4] chore: refactor, improve test coverage --- src/mi18n.js | 86 +++++++++-------- src/mi18n.test.js | 230 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 43 deletions(-) diff --git a/src/mi18n.js b/src/mi18n.js index 6fe3c00..4475994 100644 --- a/src/mi18n.js +++ b/src/mi18n.js @@ -32,7 +32,8 @@ export class I18N { */ processConfig(options) { const { location, ...restOptions } = { ...DEFAULT_CONFIG, ...options } - const parsedLocation = location.replace(/\/?$/, '/') + // Ensure location ends with a slash + const parsedLocation = location.endsWith('/') ? location : `${location}/` this.config = { location: parsedLocation, ...restOptions } const { override, preloaded = {} } = this.config const allLangs = Object.entries(this.langs).concat(Object.entries(override || preloaded)) @@ -93,14 +94,14 @@ export class I18N { */ makeSafe(str) { const mapObj = { - '{': '\\{', - '}': '\\}', - '|': '\\|', + '{': String.raw`\{`, + '}': String.raw`\}`, + '|': String.raw`\|`, } - str = str.replace(/[{}|]/g, matched => mapObj[matched]) + const escapedStr = str.replaceAll(/[{}|]/g, matched => mapObj[matched]) - return new RegExp(str, 'g') + return new RegExp(escapedStr, 'g') } /** @@ -122,23 +123,32 @@ export class I18N { * @return {String} updated string translation */ get(key, args) { - const _this = this let value = this.getValue(key) if (!value) { return } + // No replacement needed if no args provided + if (!args) { + return value + } + const tokens = value.match(/\{[^}]+?\}/g) - if (args && tokens) { - if ('object' === typeof args) { - for (const token of tokens) { - const key = token.substring(1, token.length - 1) - value = value.replace(_this.makeSafe(token), args[key] || '') - } - } else { - value = value.replace(/\{[^}]+?\}/g, args) + // No tokens to replace + if (!tokens) { + return value + } + + // Object args: replace each token with corresponding key + if (typeof args === 'object') { + for (const token of tokens) { + const tokenKey = token.slice(1, -1) + value = value.replace(this.makeSafe(token), args[tokenKey] ?? '') } + } else { + // Primitive args: replace all tokens with same value + value = value.replaceAll(/\{[^}]+?\}/g, args) } return value @@ -150,7 +160,7 @@ export class I18N { * @return {Object} processed language */ static processFile(response) { - return I18N.fromFile(response.replace(/\n\n/g, '\n')) + return I18N.fromFile(response.replaceAll('\n\n', '\n')) } /** @@ -166,7 +176,7 @@ export class I18N { const regex = /^(.+?) *?= *?([^\n]+)/ matches = regex.exec(lines[i]) if (matches) { - lang[matches[1]] = matches[2].replace(/(^\s+|\s+$)/g, '') + lang[matches[1]] = matches[2].trim() } } @@ -179,28 +189,26 @@ export class I18N { * @param {Boolean} useCache * @return {Promise} resolves response */ - loadLang(locale, useCache = true) { - const _this = this - return new Promise(function (resolve, reject) { - if (_this.loaded.indexOf(locale) !== -1 && useCache) { - _this.applyLanguage(_this.langs[locale]) - return resolve(_this.langs[locale]) - } else { - const langFile = [_this.config.location, locale, _this.config.extension].join('') - return fetchData(langFile) - .then(lang => { - const processedFile = I18N.processFile(lang) - _this.applyLanguage(locale, processedFile) - _this.loaded.push(locale) - return resolve(_this.langs[locale]) - }) - .catch(err => { - console.error(err) - const lang = _this.applyLanguage(locale) - resolve(lang) - }) - } - }) + async loadLang(locale, useCache = true) { + // Return cached language if already loaded + if (this.loaded.includes(locale) && useCache) { + this.applyLanguage(this.langs[locale]) + return this.langs[locale] + } + + // Fetch and process language file + const langFile = `${this.config.location}${locale}${this.config.extension}` + + try { + const lang = await fetchData(langFile) + const processedFile = I18N.processFile(lang) + this.applyLanguage(locale, processedFile) + this.loaded.push(locale) + return this.langs[locale] + } catch (err) { + console.error(err) + return this.applyLanguage(locale) + } } /** diff --git a/src/mi18n.test.js b/src/mi18n.test.js index 0d0de7e..abb9578 100644 --- a/src/mi18n.test.js +++ b/src/mi18n.test.js @@ -1,9 +1,7 @@ -import { test, describe } from 'node:test' import assert from 'assert' +import { describe, test } from 'node:test' import mi18n, { I18N } from './mi18n.js' -// @todo add more tests for getValue and getFallbackValue - describe('I18N', () => { const locale = 'te-ST' const testOpts = { @@ -12,6 +10,9 @@ describe('I18N', () => { 'te-ST': { testKey: 'Teeesst', testVars: 'I saw {count} {animals}', + testMultipleTokens: '{greeting} {name}, you have {count} messages', + testNoTokens: 'Just a plain string', + testMissingVars: '{hello} {world}', }, }, } @@ -57,23 +58,126 @@ describe('I18N', () => { }) assert.strictEqual(str, 'I saw 3 chickens') }) + + test('shall return undefined for missing key', () => { + const str = mi18n.get('nonExistentKey') + assert.strictEqual(str, undefined) + }) + + test('shall return string without replacement when no args', () => { + const str = mi18n.get('testVars') + assert.strictEqual(str, 'I saw {count} {animals}') + }) + + test('shall return string when no tokens to replace', () => { + const str = mi18n.get('testNoTokens', { foo: 'bar' }) + assert.strictEqual(str, 'Just a plain string') + }) + + test('shall replace with primitive arg', () => { + const str = mi18n.get('testVars', 'something') + assert.strictEqual(str, 'I saw something something') + }) + + test('shall replace multiple tokens with object', () => { + const str = mi18n.get('testMultipleTokens', { + greeting: 'Hello', + name: 'Alice', + count: 5, + }) + assert.strictEqual(str, 'Hello Alice, you have 5 messages') + }) + + test('shall handle missing object properties with empty string', () => { + const str = mi18n.get('testMissingVars', {}) + assert.strictEqual(str, ' ') + }) + + test('shall handle null/undefined values in object args', () => { + const str = mi18n.get('testMissingVars', { + hello: null, + world: undefined, + }) + assert.strictEqual(str, ' ') + }) + }) + + describe('put()', () => { + test('shall exist', () => { + assert.ok(mi18n.put) + }) + + test('shall add a key to current language', () => { + const str = mi18n.put('dynamicKey', 'Dynamic Value') + assert.strictEqual(str, 'Dynamic Value') + assert.strictEqual(mi18n.get('dynamicKey'), 'Dynamic Value') + }) + }) + + describe('getValue() and getFallbackValue()', () => { + test('getValue should return value from specified locale', () => { + const value = mi18n.getValue('testKey', 'te-ST') + assert.strictEqual(value, 'Teeesst') + }) + + test('getValue should return fallback when locale missing', () => { + const i18n = new I18N({ + override: { + 'en-US': { fallbackKey: 'Fallback Value' }, + 'fr-FR': {}, + }, + }) + const value = i18n.getValue('fallbackKey', 'fr-FR') + assert.strictEqual(value, 'Fallback Value') + }) + + test('getFallbackValue should return undefined for missing key', () => { + const value = mi18n.getFallbackValue('nonExistentKey') + assert.strictEqual(value, undefined) + }) + }) + + describe('getLangs', () => { + test('should return available languages', () => { + const langs = mi18n.getLangs + assert.ok(Array.isArray(langs)) + assert.ok(langs.length > 0) + }) }) }) describe('loadLang', () => { const location = 'https://formbuilder.online/assets/lang/' const i18n = new I18N({ location }) + test('should load de-DE', async () => { const lang = await i18n.loadLang('de-DE') assert.strictEqual(typeof lang, 'object') assert.strictEqual(Object.keys(i18n.langs)[0], 'de-DE') }) + + test('should use cache on second load', async () => { + const lang1 = await i18n.loadLang('de-DE') + const lang2 = await i18n.loadLang('de-DE', true) + assert.strictEqual(lang1, lang2) + }) + + test('should bypass cache when useCache is false', async () => { + const lang = await i18n.loadLang('de-DE', false) + assert.strictEqual(typeof lang, 'object') + }) + + test('should handle fetch error gracefully', async () => { + const lang = await i18n.loadLang('nonexistent-locale') + assert.strictEqual(typeof lang, 'object') + }) }) describe('addLanguage', () => { const locale = 'te-ST' const i18n = new I18N() - test('should load te-ST', async () => { + + test('should load te-ST with object', async () => { i18n.addLanguage(locale, { myKey: 'one thing', yourKey: "shouldn't change", @@ -83,5 +187,123 @@ describe('I18N', () => { assert.strictEqual(i18n.get('myKey'), 'one thing') assert.strictEqual(i18n.get('yourKey'), "shouldn't change") }) + + test('should load language from string format', () => { + const i18n = new I18N() + const langString = 'greeting = Hello\nfarewell = Goodbye' + i18n.addLanguage('test-lang', langString) + assert.ok(i18n.langs['test-lang']) + assert.strictEqual(i18n.langs['test-lang'].greeting, 'Hello') + assert.strictEqual(i18n.langs['test-lang'].farewell, 'Goodbye') + }) + }) + + describe('processConfig', () => { + test('should handle location without trailing slash', () => { + const i18n = new I18N({ location: 'assets/lang' }) + assert.strictEqual(i18n.config.location, 'assets/lang/') + }) + + test('should handle location with trailing slash', () => { + const i18n = new I18N({ location: 'assets/lang/' }) + assert.strictEqual(i18n.config.location, 'assets/lang/') + }) + + test('should handle override taking precedence over preloaded', () => { + const i18n = new I18N({ + override: { + 'test-OVERRIDE': { overrideKey: 'Override Value' }, + }, + preloaded: { + 'pre-LOAD': { preKey: 'Preloaded Value' }, + }, + }) + // Override takes precedence, so only override langs are used + assert.ok(i18n.langs['test-OVERRIDE']) + assert.strictEqual(i18n.langs['test-OVERRIDE'].overrideKey, 'Override Value') + }) + + test('should use first lang when locale not specified', () => { + const i18n = new I18N({ + langs: ['fr-FR', 'en-US'], + locale: null, + }) + assert.strictEqual(i18n.locale, 'fr-FR') + }) + }) + + describe('setCurrent', () => { + test('should set locale to already loaded language', async () => { + const i18n = new I18N({ + override: { + 'en-US': { test: 'value' }, + }, + }) + i18n.loaded.push('en-US') + const lang = await i18n.setCurrent('en-US') + assert.strictEqual(i18n.locale, 'en-US') + assert.strictEqual(lang.test, 'value') + }) + + test('should use default locale when none provided', async () => { + const i18n = new I18N() + await i18n.setCurrent() + assert.strictEqual(i18n.locale, 'en-US') + }) + }) + + describe('Static methods', () => { + describe('processFile()', () => { + test('should process language file text', () => { + const fileContent = 'key1 = value1\n\nkey2 = value2' + const result = I18N.processFile(fileContent) + assert.strictEqual(result.key1, 'value1') + assert.strictEqual(result.key2, 'value2') + }) + }) + + describe('fromFile()', () => { + test('should parse language file format', () => { + const fileContent = 'greeting = Hello World\nfarewell = Goodbye' + const result = I18N.fromFile(fileContent) + assert.strictEqual(result.greeting, 'Hello World') + assert.strictEqual(result.farewell, 'Goodbye') + }) + + test('should trim whitespace from values', () => { + const fileContent = 'key = value with spaces ' + const result = I18N.fromFile(fileContent) + assert.strictEqual(result.key, 'value with spaces') + }) + + test('should handle lines without equals sign', () => { + const fileContent = 'valid = value\ninvalid line\nanother = value' + const result = I18N.fromFile(fileContent) + assert.strictEqual(result.valid, 'value') + assert.strictEqual(result.another, 'value') + assert.strictEqual(result['invalid line'], undefined) + }) + }) + }) + + describe('applyLanguage', () => { + test('should merge existing language with new data', () => { + const i18n = new I18N() + i18n.langs['test'] = { existing: 'old' } + i18n.applyLanguage('test', { newKey: 'new' }) + assert.strictEqual(i18n.langs.test.existing, 'old') + assert.strictEqual(i18n.langs.test.newKey, 'new') + }) + + test('should apply overrides', () => { + const i18n = new I18N({ + override: { + test: { overridden: 'override value' }, + }, + }) + i18n.applyLanguage('test', { overridden: 'original', normal: 'value' }) + assert.strictEqual(i18n.langs.test.overridden, 'override value') + assert.strictEqual(i18n.langs.test.normal, 'value') + }) }) }) From 0115505f6bbad8c629e44a3c2c20ebb99e6b9fb9 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 13 Nov 2025 01:58:40 +0000 Subject: [PATCH 3/4] Update src/mi18n.js remove redundant applyLanguage Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mi18n.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/mi18n.js b/src/mi18n.js index 4475994..4865e16 100644 --- a/src/mi18n.js +++ b/src/mi18n.js @@ -192,10 +192,8 @@ export class I18N { async loadLang(locale, useCache = true) { // Return cached language if already loaded if (this.loaded.includes(locale) && useCache) { - this.applyLanguage(this.langs[locale]) return this.langs[locale] } - // Fetch and process language file const langFile = `${this.config.location}${locale}${this.config.extension}` From 45657cbdc8339b5cef21dd43a422bb071f121560 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Thu, 13 Nov 2025 01:59:02 +0000 Subject: [PATCH 4/4] Update src/mi18n.test.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mi18n.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mi18n.test.js b/src/mi18n.test.js index abb9578..b8861a2 100644 --- a/src/mi18n.test.js +++ b/src/mi18n.test.js @@ -218,7 +218,7 @@ describe('I18N', () => { 'pre-LOAD': { preKey: 'Preloaded Value' }, }, }) - // Override takes precedence, so only override langs are used + // When override is provided, preloaded is not used assert.ok(i18n.langs['test-OVERRIDE']) assert.strictEqual(i18n.langs['test-OVERRIDE'].overrideKey, 'Override Value') })