From f8364451c357c3631620b0e0f20206d707c62558 Mon Sep 17 00:00:00 2001 From: Lars Mortenson Date: Mon, 1 Nov 2021 16:56:22 -0400 Subject: [PATCH] add resetDefaultValueLocale option --- README.md | 8 +- src/helpers.js | 57 ++++++++++---- src/transform.js | 36 ++++++++- test/helpers/mergeHashes.test.js | 36 +++++++++ test/locales/ar/test_reset.json | 3 + test/locales/en/test_reset.json | 3 + test/locales/fr/test_reset.json | 3 + test/parser.test.js | 126 ++++++++++++++++++++++++++++++- 8 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 test/locales/ar/test_reset.json create mode 100644 test/locales/en/test_reset.json create mode 100644 test/locales/fr/test_reset.json diff --git a/README.md b/README.md index 6fcce131..ca952a8c 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ module.exports = { failOnWarnings: false, // Exit with an exit code of 1 on warnings - customValueTemplate: null + customValueTemplate: null, // If you wish to customize the value output the value as an object, you can set your own format. // ${defaultValue} is the default value you set in your translation function. // Any other custom property will be automatically extracted. @@ -206,6 +206,12 @@ module.exports = { // message: "${defaultValue}", // description: "${maxLength}", // t('my-key', {maxLength: 150}) // } + + resetDefaultValueLocale: null + // The locale to compare with default values to determine whether a default value has been changed. + // If this is set and a default value differs from a translation in the specified locale, all entries + // for that key across locales are reset to the default value, and existing translations are moved to + // the `_old` file. } ``` diff --git a/src/helpers.js b/src/helpers.js index 0b8dfa6a..ca2803dd 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -118,19 +118,22 @@ function dotPathToHash(entry, target = {}, options = {}) { * Takes a `source` hash and makes sure its value * is pasted in the `target` hash, if the target * hash has the corresponding key (or if `options.keepRemoved` is true). - * @returns An `{ old, new, mergeCount, pullCount, oldCount }` object. + * @returns An `{ old, new, mergeCount, pullCount, oldCount, reset, resetCount }` object. * `old` is a hash of values that have not been merged into `target`. * `new` is `target`. `mergeCount` is the number of keys merged into * `new`, `pullCount` is the number of context and plural keys added to * `new` and `oldCount` is the number of keys that were either added to `old` or * `new` (if `options.keepRemoved` is true and `target` didn't have the corresponding - * key). + * key) and `reset` is the keys that were reset due to not matching default values, + * and `resetCount` which is the number of keys reset. */ -function mergeHashes(source, target, options = {}) { +function mergeHashes(source, target, options = {}, resetValues = {}) { let old = {} + let reset = {} let mergeCount = 0 let pullCount = 0 let oldCount = 0 + let resetCount = 0 const keepRemoved = options.keepRemoved || false const pluralSeparator = options.pluralSeparator || '_' @@ -148,12 +151,25 @@ function mergeHashes(source, target, options = {}) { old[key] = nested.old } } else if (target[key] !== undefined) { - if (typeof source[key] === 'string' || Array.isArray(source[key])) { - target[key] = source[key] - mergeCount += 1 - } else { + if (typeof source[key] !== 'string' && !Array.isArray(source[key])) { old[key] = source[key] oldCount += 1 + } else { + if ( + (options.resetAndFlag && + !isPlural(key) && + typeof source[key] === 'string' && + source[key] !== target[key]) || + resetValues[key] + ) { + old[key] = source[key] + oldCount += 1 + reset[key] = true + resetCount += 1 + } else { + target[key] = source[key] + mergeCount += 1 + } } } else { // support for plural in keys @@ -183,7 +199,15 @@ function mergeHashes(source, target, options = {}) { } } - return { old, new: target, mergeCount, pullCount, oldCount } + return { + old, + new: target, + mergeCount, + pullCount, + oldCount, + reset, + resetCount, + } } /** @@ -205,9 +229,16 @@ function transferValues(source, target) { } } +const pluralSuffixes = ['zero', 'one', 'two', 'few', 'many', 'other'] + +function isPlural(key) { + return pluralSuffixes.some((suffix) => key.endsWith(suffix)) +} + function hasRelatedPluralKey(rawKey, source) { - const suffixes = ['zero', 'one', 'two', 'few', 'many', 'other'] - return suffixes.some((suffix) => source[`${rawKey}${suffix}`] !== undefined) + return pluralSuffixes.some( + (suffix) => source[`${rawKey}${suffix}`] !== undefined + ) } function getSingularForm(key, pluralSeparator) { @@ -219,10 +250,8 @@ function getSingularForm(key, pluralSeparator) { } function getPluralSuffixPosition(key) { - const suffixes = ['zero', 'one', 'two', 'few', 'many', 'other'] - - for (let i = 0, len = suffixes.length; i < len; i++) { - if (key.endsWith(suffixes[i])) return i + for (let i = 0, len = pluralSuffixes.length; i < len; i++) { + if (key.endsWith(pluralSuffixes[i])) return i } return -1 diff --git a/src/transform.js b/src/transform.js index 225e6362..c9cdf557 100644 --- a/src/transform.js +++ b/src/transform.js @@ -33,6 +33,7 @@ export default class i18nTransform extends Transform { namespaceSeparator: ':', pluralSeparator: '_', output: 'locales/$LOCALE/$NAMESPACE.json', + resetDefaultValueLocale: null, sort: false, useKeysAsDefaultValue: false, verbose: false, @@ -125,8 +126,20 @@ export default class i18nTransform extends Transform { } _flush(done) { - for (const locale of this.options.locales) { + let maybeSortedLocales = this.options.locales + if (this.options.resetDefaultValueLocale) { + // ensure we process the reset locale first + maybeSortedLocales.sort((a) => + a === this.options.resetDefaultValueLocale ? -1 : 1 + ) + } + + // Tracks keys to reset by namespace + let resetValues = {} + + for (const locale of maybeSortedLocales) { const catalog = {} + const resetAndFlag = this.options.resetDefaultValueLocale === locale let countWithPlurals = 0 let uniqueCount = this.entries.length @@ -193,7 +206,23 @@ export default class i18nTransform extends Transform { old: oldKeys, mergeCount, oldCount, - } = mergeHashes(existingCatalog, catalog[namespace], this.options) + reset: resetFlags, + resetCount, + } = mergeHashes( + existingCatalog, + catalog[namespace], + { + ...this.options, + resetAndFlag, + }, + resetValues[namespace] + ) + + // record values to be reset + // assumes that the 'default' namespace is processed first + if (resetAndFlag && !resetValues[namespace]) { + resetValues[namespace] = resetFlags + } // restore old translations const { old: oldCatalog, mergeCount: restoreCount } = mergeHashes( @@ -218,6 +247,9 @@ export default class i18nTransform extends Transform { } else { console.log(`Removed keys: ${oldCount}`) } + if (this.options.resetDefaultValueLocale) { + console.log(`Reset keys: ${resetCount}`) + } console.log() } diff --git a/test/helpers/mergeHashes.test.js b/test/helpers/mergeHashes.test.js index 68297174..ecc1bbb2 100644 --- a/test/helpers/mergeHashes.test.js +++ b/test/helpers/mergeHashes.test.js @@ -228,4 +228,40 @@ describe('mergeHashes helper function', () => { assert.strictEqual(res.oldCount, 0) done() }) + + it('resets keys to the target value if they are flagged in the resetKeys object', (done) => { + const source = { key1: 'key1', key2: 'key2' } + const target = { key1: 'changedKey1', key2: 'changedKey2' } + const res = mergeHashes(source, target, {}, { key1: true }) + + assert.deepEqual(res.new, { key1: 'changedKey1', key2: 'key2' }) + assert.deepEqual(res.old, { key1: 'key1' }) + assert.strictEqual(res.resetCount, 1) + done() + }) + + it('ignores keys if they are plurals', (done) => { + const source = { key1_one: 'key1', key2: 'key2' } + const target = { key1_one: 'changedKey1', key2: 'changedKey2' } + const res = mergeHashes(source, target, {}, { key1: true }) + + assert.deepEqual(res.new, { key1_one: 'key1', key2: 'key2' }) + assert.deepEqual(res.old, {}) + assert.strictEqual(res.resetCount, 0) + done() + }) + + it('resets and flags keys if the resetAndFlag value is set', (done) => { + const source = { key1: 'key1', key2: 'key2' } + const target = { key1: 'changedKey1', key2: 'key2' } + const res = mergeHashes(source, target, { + resetAndFlag: true, + }) + + assert.deepEqual(res.new, { key1: 'changedKey1', key2: 'key2' }) + assert.deepEqual(res.old, { key1: 'key1' }) + assert.deepEqual(res.reset, { key1: true }) + assert.strictEqual(res.resetCount, 1) + done() + }) }) diff --git a/test/locales/ar/test_reset.json b/test/locales/ar/test_reset.json new file mode 100644 index 00000000..1fe8fdf2 --- /dev/null +++ b/test/locales/ar/test_reset.json @@ -0,0 +1,3 @@ +{ + "key": "ar_translation" +} diff --git a/test/locales/en/test_reset.json b/test/locales/en/test_reset.json new file mode 100644 index 00000000..58c8679b --- /dev/null +++ b/test/locales/en/test_reset.json @@ -0,0 +1,3 @@ +{ + "key": "en_translation" +} diff --git a/test/locales/fr/test_reset.json b/test/locales/fr/test_reset.json new file mode 100644 index 00000000..d8a743db --- /dev/null +++ b/test/locales/fr/test_reset.json @@ -0,0 +1,3 @@ +{ + "key": "defaultTranslation" +} diff --git a/test/parser.test.js b/test/parser.test.js index 50f5234a..b45243aa 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -1,8 +1,9 @@ -import { assert } from 'chai' +import { assert, expect } from 'chai' import Vinyl from 'vinyl' import fs from 'fs' import i18nTransform from '../src/transform' import path from 'path' +import sinon from 'sinon' const enLibraryPath = path.normalize('en/translation.json') const arLibraryPath = path.normalize('ar/translation.json') @@ -661,6 +662,129 @@ describe('parser', () => { }) describe('options', () => { + describe('resetDefaultValueLocale', () => { + it('will not reset a key if a default value has not changed in the locale', (done) => { + const i18nextParser = new i18nTransform({ + resetDefaultValueLocale: 'fr', + output: 'test/locales/$LOCALE/$NAMESPACE.json', + locales: ['en', 'ar', 'fr'], + }) + + const fakeFile = new Vinyl({ + contents: Buffer.from( + `t('test_reset:key', { + defaultValue: 'defaultTranslation', + })` + ), + path: 'file.js', + }) + + let enResult, arResult + i18nextParser.on('data', (file) => { + if (file.relative.endsWith(path.normalize('en/test_reset.json'))) { + enResult = JSON.parse(file.contents) + } else if ( + file.relative.endsWith(path.normalize('ar/test_reset.json')) + ) { + arResult = JSON.parse(file.contents) + } + }) + i18nextParser.once('end', () => { + assert.deepEqual(enResult, { + key: 'en_translation', + }) + assert.deepEqual(arResult, { + key: 'ar_translation', + }) + done() + }) + + i18nextParser.end(fakeFile) + }) + + it('will reset a key if a default value has changed in the locale', (done) => { + const i18nextParser = new i18nTransform({ + resetDefaultValueLocale: 'fr', + output: 'test/locales/$LOCALE/$NAMESPACE.json', + locales: ['en', 'ar', 'fr'], + }) + + const newString = 'newTranslation' + + const fakeFile = new Vinyl({ + contents: Buffer.from( + `t('test_reset:key', { + defaultValue: '${newString}', + })` + ), + path: 'file.js', + }) + + let enResult, arResult + i18nextParser.on('data', (file) => { + if (file.relative.endsWith(path.normalize('en/test_reset.json'))) { + enResult = JSON.parse(file.contents) + } else if ( + file.relative.endsWith(path.normalize('ar/test_reset.json')) + ) { + arResult = JSON.parse(file.contents) + } + }) + i18nextParser.once('end', () => { + assert.deepEqual(enResult, { + key: newString, + }) + assert.deepEqual(arResult, { + key: newString, + }) + done() + }) + + i18nextParser.end(fakeFile) + }) + }) + + describe('verbose', () => { + beforeEach(() => { + sinon.spy(console, 'log') + }) + + afterEach(() => { + console.log.restore(); + }) + + describe('with defaultResetLocale', () => { + it('logs the number of values reset', (done) => { + const i18nextParser = new i18nTransform({ + verbose: true, + resetDefaultValueLocale: 'fr', + output: 'test/locales/$LOCALE/$NAMESPACE.json', + locales: ['en', 'fr'], + }) + + const newString = 'newTranslation' + + const fakeFile = new Vinyl({ + contents: Buffer.from( + `t('test_reset:key', { + defaultValue: '${newString}', + })` + ), + path: 'file.js', + }) + + i18nextParser.on('data', () => {}) + + i18nextParser.once('end', () => { + assert(console.log.calledWith('Reset keys: 1')) + done() + }) + + i18nextParser.end(fakeFile) + }) + }) + }) + it('handles output with $LOCALE and $NAMESPACE var', (done) => { let result const i18nextParser = new i18nTransform({