Merge pull request #955 from sharetribe/translations-cli

Translations management CLI
This commit is contained in:
Hannu Lyytikäinen 2018-11-27 12:50:01 +02:00 committed by GitHub
commit e2aca7ac5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 379 additions and 3 deletions

View file

@ -19,13 +19,13 @@ way to update this template, but currently, we follow a pattern:
[#960](https://github.com/sharetribe/flex-template-web/pull/960)
* [fix] Remove unused translation keys and update PasswordChangePage title
[#959](https://github.com/sharetribe/flex-template-web/pull/959)
* [fix] Add translations CLI tool [#955](https://github.com/sharetribe/flex-template-web/pull/955)
## [v2.3.2] 2018-11-20
* [fix] Take 2: don't set currentUserHasListings if fetched listing is in draft state.
[#956](https://github.com/sharetribe/flex-template-web/pull/956)
* [fix] PriceFilter styles
[#954](https://github.com/sharetribe/flex-template-web/pull/954)
* [fix] PriceFilter styles [#954](https://github.com/sharetribe/flex-template-web/pull/954)
[v2.3.2]: https://github.com/sharetribe/flex-template-web/compare/v2.3.1...v2.3.2
@ -322,4 +322,3 @@ way to update this template, but currently, we follow a pattern:
## v0.2.0
* Starting a change log for Flex Template for Web.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View file

@ -161,3 +161,26 @@ in tests. To change the translation file used in tests change the `messages` var
```js
import messages from '../translations/es.json';
```
## Managing translations
In case you have added a new language translation file and are pulling translation updates to
`en.json` from the upstream repo there is a command line tool to help keeping the translation files
in sync. Running the following command in the project root
```
node translations.js
```
will start a command line application:
![Translations CLI](./assets/translations/translations_cli.gif)
The command line application can be used to match a translation file against the English
translations. If your new translations file follows the `<LANG CODE>.json` naming, the CLI will pick
it up automatically. In order to improve readability, you can add the language name to the
`TARGET_LANG_NAMES` map in `translations.js` if it is not yet in there and the CLI will use the
correct name for your language instead of the language code when prompting about translations.
In case you wish to use something else than English as the source language, modify the `SOURCE_LANG`
object in `translations.js` to match your needs.

View file

@ -56,9 +56,11 @@
"url": "^0.11.0"
},
"devDependencies": {
"chalk": "^2.4.1",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"enzyme-to-json": "^3.3.3",
"inquirer": "^6.2.0",
"nodemon": "^1.17.2",
"nsp": "^3.2.1",
"nsp-preprocessor-yarn": "^1.0.1",

300
translations.js Normal file
View file

@ -0,0 +1,300 @@
/**
* A translations too that can be used to keep a translation file(s)
* up to date with one source language file.
*
* Possible target languages are resolved from the translation files
* available. When adding a new tranlation file, the language name can
* be added to the TARGET_LANG_NAMES map for clearer prompt messages.
* By default the translations are matched agains English translations
* but that can be changed by modifying the SOURCE_LANG.
*/
const inquirer = require('inquirer');
const difference = require('lodash/difference');
const fs = require('fs');
const chalk = require('chalk');
const PATH = './src/translations/';
const SOURCE_LANG = { name: 'English', code: 'en' };
const TARGET_LANG_NAMES = {
es: 'Spanish',
de: 'German',
fr: 'French',
};
class BreakToRun {}
class BreakToRunWithTarget {}
/**
* Resolves translation file path for a lang code: en, es, de, etc.
*/
const filePath = lang => `${PATH}${lang}.json`;
/**
* Resolves name of a target language based on
* a language code: en, es, de, etc.
*
* If a lang name is not found, language code is returned.
*/
const targetLangName = code => {
const name = TARGET_LANG_NAMES[code];
return name || code;
};
/**
* Resolve a list of target language choices by finding all possible
* translation files and filtering out the SOURCE_LANG. Provides a list
* of objects that can be passed to inquirer.
*
* Relies on translation file naming: <lang code>.json
*/
const targetLangChoices = () => {
const folder = './src/translations/';
const filenames = fs.readdirSync(folder);
const choices = filenames
.filter(name => name.endsWith('.json'))
.map(name => name.split('.')[0])
.filter(code => code !== SOURCE_LANG.code)
.map(code => {
return {
name: targetLangName(code),
value: code,
};
});
return choices;
};
/**
* Sorts an object by key.
*
* Relies on an object keeping the order in which entries are added for string keys.
* See: http://exploringjs.com/es6/ch_oop-besides-classes.html#_traversal-order-of-properties
*/
const sort = obj => {
const sorted = {};
Object.keys(obj)
.sort()
.forEach(key => (sorted[key] = obj[key]));
return sorted;
};
/**
* Reads a file with JSON content and returns
* a JS object.
*
* @param filepath
*/
const readFileToJSON = filepath => {
const rawdata = fs.readFileSync(filepath);
return JSON.parse(rawdata);
};
/**
* Run the application.
*
*/
const run = () => {
const choices = targetLangChoices();
selectLanguage(choices)
.then(answers => {
runWithTarget(answers.lang);
})
.catch(err => {
console.log(chalk.red(`An error occurred due to: ${err.message}`));
});
};
/**
* When a language is selected this will prompt
* the user about adding translations to that language.
*
* @param {String} targetLang Target language code
*/
const runWithTarget = targetLang => {
let key;
let translation;
const sourceLang = SOURCE_LANG.code;
const source = readFileToJSON(filePath(sourceLang));
const target = readFileToJSON(filePath(targetLang));
const sourceKeys = Object.keys(source);
const targetKeys = Object.keys(target);
const diff = difference(sourceKeys, targetKeys);
if (diff.length === 0) {
console.log(`No translations missing from the ${targetLangName(targetLang)} translations`);
run();
} else {
translateLanguage(targetLang, source, target, diff);
}
};
/**
* Does the actual prompting for translations.
*
* @param {String} targetLang Target language code
* @param {Object} source Source translations
* @param {Object} target Target translations
* @param {Array} diff Missing keys in target
*/
const translateLanguage = (targetLang, source, target, diff) => {
selectKey(targetLang, diff, source, target)
.then(answers => {
key = answers.key;
if (key === null) {
throw new BreakToRun();
}
if (key != null) {
return addTranslation(targetLang, key, source);
}
})
.then(answers => {
translation = answers.value;
return confirmTranslation(targetLang, key, translation);
})
.then(answers => {
// y|Y|n|N
const confirmation = answers.value;
if (/n/i.test(confirmation)) {
console.log('Discarding new translation');
throw new BreakToRunWithTarget();
}
target[key] = translation;
const sorted = sort(target);
const data = JSON.stringify(sorted, null, 2);
return updateTranslationsFile(data, targetLang);
})
.then(() => {
runWithTarget(targetLang);
})
.catch(err => {
if (err instanceof BreakToRun) {
// break out of Promise chain, start over
run();
} else if (err instanceof BreakToRunWithTarget) {
// break out of Promise chain, continue with this language
runWithTarget(targetLang);
} else {
console.log(chalk.red(`An error occurred due to: ${err.message}`));
}
});
};
/**
* Prompt for selecting the language of which
* translations are inspected.
*
* @return a Promise
*/
const selectLanguage = choices => {
return inquirer.prompt([
{
type: 'list',
name: 'lang',
message: 'Which language would you like to inspect? (Or hit Ctrl+C to quit)',
choices: choices,
},
]);
};
/**
* Prompt for selecting a key that is missing from the target language translations.
*
* @param {String} targetLang The language that is being translated
* @param {Array} diff Keys missing frrom target language translations
*
* @return a Promise
*/
const selectKey = (targetLang, diff, source, target) => {
const choices = [{ name: 'Back to languages', value: null }, ...diff];
return inquirer.prompt([
{
type: 'list',
name: 'key',
message: `The following translation keys (${
diff.length
}) are missing from the ${targetLangName(
targetLang
)} translations. Select a key to add a translation.`,
choices: choices,
pageSize: 30,
},
]);
};
/**
* Prompt for a new translation in target language.
*
* @param {String} targetLang The language that is being translated
* @param {String} key Key for the new translation
* @param {Object} source Source language translations
*
* @return a Promise
*/
const addTranslation = (targetLang, key, source) => {
return inquirer.prompt([
{
type: 'input',
name: 'value',
message: `Please provide a translation in ${targetLangName(
targetLang
)} for the key ${chalk.blueBright(key)}. The current ${
SOURCE_LANG.name
} translation is ${chalk.green(source[key])} \n`,
},
]);
};
/**
* Prompt for a confirming a new translation with 'y' or 'N'.
*
* @param {String} targetLang The language that is being translated
* @param {String} key Key for the new translation
* @param {Object} source Source language translations
*
* @return a Promise with with answers.value being 'y' or 'N'
*/
const confirmTranslation = (targetLang, key, translation) => {
return inquirer.prompt([
{
type: 'input',
name: 'value',
message: `Do you want to add the ${targetLangName(targetLang)} translation ${chalk.green(
translation
)} for the key ${chalk.blueBright(key)}? (y/n)`,
validate: value => /y/i.test(value) || /n/i.test(value),
},
]);
};
/**
* Update translations file.
*
* @param {String} data String data to be written to file
* @param {String} targetLang Language code of the translation file
*
* @return a Promise that resolves when file is written
*/
const updateTranslationsFile = (data, targetLang) => {
return new Promise((resolve, reject) => {
fs.writeFile(filePath(targetLang), data, err => {
if (err) {
reject(err);
}
console.log(chalk.bold('The translation file is updated'));
resolve();
});
});
};
run();

View file

@ -1512,6 +1512,11 @@ change-emitter@^0.1.2:
version "0.1.6"
resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
chardet@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
charenc@~0.0.1:
version "0.0.2"
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
@ -3005,6 +3010,15 @@ external-editor@^2.0.4:
jschardet "^1.4.2"
tmp "^0.0.33"
external-editor@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27"
integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==
dependencies:
chardet "^0.7.0"
iconv-lite "^0.4.24"
tmp "^0.0.33"
extglob@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
@ -3819,6 +3833,13 @@ iconv-lite@0.4.23, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
dependencies:
safer-buffer ">= 2.1.2 < 3"
icss-replace-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
@ -3925,6 +3946,25 @@ inquirer@3.3.0, inquirer@^3.0.6, inquirer@^3.3.0:
strip-ansi "^4.0.0"
through "^2.3.6"
inquirer@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8"
integrity sha512-QIEQG4YyQ2UYZGDC4srMZ7BjHOmNk1lR2JQj5UknBapklm6WHA+VVH7N+sUdX3A7NeCfGF8o4X1S3Ao7nAcIeg==
dependencies:
ansi-escapes "^3.0.0"
chalk "^2.0.0"
cli-cursor "^2.1.0"
cli-width "^2.0.0"
external-editor "^3.0.0"
figures "^2.0.0"
lodash "^4.17.10"
mute-stream "0.0.7"
run-async "^2.2.0"
rxjs "^6.1.0"
string-width "^2.1.0"
strip-ansi "^4.0.0"
through "^2.3.6"
internal-ip@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c"
@ -7312,6 +7352,13 @@ rx-lite@*, rx-lite@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
rxjs@^6.1.0:
version "6.3.3"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55"
integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw==
dependencies:
tslib "^1.9.0"
safe-buffer@5.1.1, safe-buffer@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
@ -8106,6 +8153,11 @@ trim-right@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
tslib@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==
tty-browserify@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"