Commit ef2b7a0c authored by Viacheslav Pavlov's avatar Viacheslav Pavlov Committed by Matija Obreza
Browse files

Module: i18n

- Handles translations
parent 4f3da94c
......@@ -13,6 +13,7 @@
"clean": "lerna run clean"
},
"workspaces": [
"packages/i18n"
],
"devDependencies": {
"lerna": "^3.20.2"
......
const fs = require('fs');
const chalk = require('chalk');
module.exports = {
options: {
debug: true,
attr: false,
removeUnusedKeys: true,
sort: true,
func: {
list: ['t'], // 'i18next.t', 'i18n.t',
extensions: ['.tsx' ] // '.js', '.jsx',
},
trans: false,
// {
// extensions: ['.tsx' ],
// fallbackKey: (ns, value) => {
// return value;
// }
// },
lngs: ['en'],
ns: [
'translations',
'common',
],
defaultNs: 'translations',
defaultValue: null, // '__STRING_NOT_TRANSLATED__',
resource: {
loadPath: 'locales/{{lng}}/{{ns}}.json',
savePath: 'locales/{{lng}}/{{ns}}.json'
},
nsSeparator: ':', // namespace separator
keySeparator: '.', // key separator
interpolation: {
prefix: '{{',
suffix: '}}'
}
},
transform: function customTransform(file, enc, done) {
'use strict';
const parser = this.parser;
console.log(`Scanning ${file.path}`);
const content = fs.readFileSync(file.path, enc);
let count = 0;
parser.parseFuncFromString(content, { list: ['i18next._', 'i18next.__', 't'] }, (key, options) => {
parser.set(key, { ...options, nsSeparator: ':',
keySeparator: '.' });
++count;
});
if (count > 0) {
console.log(`i18next-scanner: count=${chalk.cyan(count)}, file=${chalk.yellow(JSON.stringify(file.relative))}`);
}
done();
}
};
{
"action": {
"submit": null
},
"label": {
"loadingData": "Loading data..."
}
}
{
"user": {
"public": {
"form": {
"login": {
"label": {
"password": null,
"username": null
}
}
}
}
}
}
{
"name": "@grin-global/i18n",
"version": "0.0.1",
"license": "Apache-2.0",
"scripts": {
"precompile": "yarn run i18n:generateI18n",
"i18n:generateI18n": "node src/generateI18n.ts",
"i18n:scanI18nDuplicates": "ts-node src/duplicateDetector.ts",
"i18n:i18nscan": "i18next-scanner --config i18next-scanner.config.js '../**/src/**/*.tsx'"
},
"dependencies": {
"@grin-global/ui-core": "file:../ui-core",
"axios": "^0.19.2",
"i18next": "^19.0.3",
"i18next-browser-languagedetector": "^4.0.1",
"i18next-express-middleware": "^1.9.1",
"i18next-sync-fs-backend": "^1.1.1",
"i18next-xhr-backend": "^3.2.2"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.8.4",
"@types/enzyme": "^3.10.5",
"@types/jest": "^25.1.2",
"@types/react": "16.9.19",
"@types/react-router": "5.1.4",
"@types/react-router-dom": "5.1.3",
"@types/redux-form": "8.2.0",
"@types/webpack-env": "^1.15.1",
"@typescript-eslint/eslint-plugin": "^2.17.0",
"@typescript-eslint/eslint-plugin-tslint": "^2.17.0",
"@typescript-eslint/parser": "^2.18.0",
"awesome-typescript-loader": "^5.2.1",
"babel-eslint": "^10.0.3",
"babel-plugin-module-resolver": "^4.0.0",
"babel-plugin-react-transform": "^3.0.0",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"babel-preset-typescript": "^7.0.0-alpha.19",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.10.0",
"eslint-loader": "^3.0.3",
"eslint-plugin-import": "^2.20.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prefer-arrow": "^1.1.7",
"eslint-plugin-react": "^7.18.0",
"file-system": "^2.2.2",
"i18next-scanner": "^2.10.3",
"lerna": "^3.20.2",
"rimraf": "^3.0.1",
"ts-node": "^8.6.2",
"tslint": "^6.0.0",
"tslint-loader": "^3.5.4",
"tslint-react": "^4.2.0",
"typescript": "^3.7.5"
}
}
const languages = [
{ label: 'English', short: 'en' },
{ label: 'Arabic', short: 'ar', rtl: true },
{ label: 'Czech', short: 'cs' },
{ label: 'German', short: 'de' },
{ label: 'Spanish', short: 'es' },
{ label: 'Farsi', short: 'fa', rtl: true },
{ label: 'French', short: 'fr' },
{ label: 'Portuguese', short: 'pt' },
{ label: 'Russian', short: 'ru' },
{ label: 'Chinese', short: 'zh' },
];
export default languages;
import languages from '@grin-global/i18n/data/Languages';
export default function detectLocaleFromPath(virtualPath: string, path: string, index: number) {
if (virtualPath && path.startsWith(virtualPath)) {
path = path.substring(virtualPath.length);
}
let found = 'en';
const language = path.match(/\/([a-zA-Z-]*)/g);
if (language instanceof Array) {
const foundLang = language[index].replace('/', '');
languages.some((item, i, arr) => {
if (item.short === foundLang) {
found = foundLang;
return true;
}
});
}
return found;
}
const fastGlob = require('fast-glob');
const fileStream = require('fs');
const path = require('path');
const readlineSync = require('readline-sync');
class II18n {
public filePath: string;
public key: string;
public value: string;
public static toString = (i18n: II18n) => {
let namespace = '';
if (path.dirname(i18n.filePath) === './locales/en') {
namespace = 'common:';
} else if (path.dirname(i18n.filePath) === './src') {
namespace = '';
} else {
namespace = path.basename(path.dirname(i18n.filePath)) + '.';
}
return `${i18n.filePath}: ${i18n.key} ..... t('${namespace}${i18n.key}')`;
}
}
const duplicates = new Map<string, II18n[]>();
async function main() {
console.log('Running...');
const dupes = fastGlob(['./locales/en/common.json', '../../**/src/**/translations.json', '../../**/src/translations.json'])
.then((paths) => paths.map((path) => testFile(path)));
console.log('Awawiting results...', dupes);
await dupes;
// console.log(duplicates);
const duplicateCount = [...duplicates.values() ].filter((d) => d.length > 1).length;
console.log(`Found ${color(bold(duplicateCount), COLOR_BG_RED)} duplicate translations.`);
for (const value of duplicates.keys()) {
// console.log(`The val ${value}`);
const d = duplicates.get(value);
if (d.length > 1) {
await handleDuplicate(value, d);
}
}
}
const testFile = (path) => {
const content = JSON.parse(fileStream.readFileSync(path, 'utf8'));
testDuplicate(content, path);
};
const testDuplicate = (json, filePath, currentPath = undefined) => {
Object.entries(json).map(([key, value]) => {
if (typeof value === 'string') {
const str = value as string;
if (duplicates.get(str)) {
duplicates.get(str).push({ filePath, key: (currentPath ? `${currentPath}.` : '') + key, value: str });
} else {
duplicates.set(str, [ { filePath, key: (currentPath ? `${currentPath}.` : '') + key, value: str } ]);
}
} else {
testDuplicate(value, filePath, (currentPath ? `${currentPath}.` : '') + key);
}
});
};
const prettyOutput = (value: string, duplicates: II18n[]) => {
console.log(`WARN: Found ${color(bold(duplicates.length), COLOR_BG_RED)} duplicates for ${color(bold(value), COLOR_FG_GREEN)}`);
duplicates.forEach(async (duplicate) => {
console.log(II18n.toString(duplicate));
});
console.log('');
};
function handleDuplicate(value: string, duplicates: II18n[]) {
console.log('\n\n');
prettyOutput(value, duplicates);
const answer = readlineSync.question(`What with ${color(bold(value), COLOR_FG_GREEN)}: `);
if (answer) {
console.log(`Doing ${color(answer, COLOR_FG_GREEN)}`);
}
}
// Terminal colors
const bold = (txt) => color(`${txt}`, 1);
const color = (txt, color: number) => `${COLOR(color)}${txt}${COLOR_RESET}`;
const COLOR_RESET = '\x1b[0m';
const COLOR_BRIGHT = '\x1b[1m';
const COLOR = (color: number) => `\x1b[${color}m`;
const COLOR_FG_GREEN = 32;
const COLOR_FG_YELLOW = 33;
const BgBlack = 40;
const COLOR_BG_RED = 41;
const COLOR_BG_GREEN = 42;
const COLOR_BG_YELLOW = 43;
const BgBlue = 44;
const BgMagenta = 45;
const BgCyan = 46;
const BgWhite = 47;
main().catch((e) => {
console.log(e);
process.exit(-1);
});
const fg = require('fast-glob');
const _ = require('lodash');
const fs = require('fs');
const md5 = require('js-md5');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
const getPrefix = (path) => path.substring(path.indexOf('./src/') + './src/'.length, path.indexOf('/translations.json'));
// TODO fix generated paths
fg(['../../**/src/**/translations.json', '../../**/src/translations.json'])
.then((entries) => entries.sort((a, b) => getPrefix(a).localeCompare(getPrefix(b))))
.then((entries) => {
let result = {};
entries.forEach((path) => {
result = { ...result, ...getTranslations(path) };
});
return result;
})
.then((content) => fs.writeFileSync('locales/en/translations.json', JSON.stringify(content, null, 2)))
.then(() => {
const content = fs.readFileSync('locales/en/translations.json');
JSON.parse(content);
}).catch((error) => {
console.log('Error reading resulting locales/en/translations.json', error);
process.exit(-1);
})
.then(() => generateHashedLocales());
const generateHashedLocales = () => {
rimraf('./generated/locales', (err) => { // delete old generated locales
fg(['./locales/**/translations.json', './locales/**/common.json']) // scan for all translations files
.then((entries) => {
const localeHashMapping = {}; // map commonPath -> pathWithHash
mkdirp('./generated/locales', () => {
entries.forEach((path) => {
const fileContent = fs.readFileSync(path); // reading content for generating hash
const contentHash = md5(fileContent);
const newFilePath = path.replace('locales', 'generated/locales').replace('.json', `-${ contentHash }.json`); // adding hash before .json
const generatedLocaleDirPath = newFilePath.substring(0, newFilePath.lastIndexOf('/')); // getting all but the filename as folder of locale lang
mkdirp.sync(generatedLocaleDirPath); // create dir if no exist
fs.writeFileSync(newFilePath, fileContent, {flag: 'wx'});
localeHashMapping[path.substring(1)] = newFilePath.replace('./generated', ''); // deleting '/generated' from new path for further loading
// console.log(`Generated ${ newFilePath } for ${ path }`);
});
fs.writeFileSync('./generated/locales/localesMapping.json', JSON.stringify(localeHashMapping), {encoding: 'utf8'}); // after all locales moved creating file with mappings
});
});
});
};
const getTranslations = (path) => {
const prefix = getPrefix(path);
console.log('Loading translations of module', prefix);
const fileContent = fs.readFileSync(path, 'utf8');
try {
const i18n = JSON.parse(fileContent);
if (prefix === '/') {
return i18n;
} else {
const moduleI18n = {};
moduleI18n[prefix] = i18n;
return moduleI18n;
}
} catch (error) {
console.log(`Invalid JSON in ${path}`, error);
process.exit(-1);
}
};
// @ts-ignore
import i18n from 'i18next';
import axios from 'axios';
// eslint-disable-next-line no-restricted-imports
import optionsBase from '@grin-global/i18n/options-base';
// eslint-disable-next-line no-restricted-imports
import langDetector from '@grin-global/i18n/langDetector';
import XHR from 'i18next-xhr-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
const lngDetector = new LanguageDetector();
lngDetector.addDetector(langDetector);
// copied from client.tsx
const virtualPath = document.baseURI.replace(/^(https?:\/\/[^\/]+)?(.*)\/$/, '$2');
const backend = {
loadPath: `${virtualPath}/locales/{{lng}}/{{ns}}.json`,
ajax: (url, options, callback, data) => {
// @ts-ignore
const localeMapping = JSON.parse(window.localesMapping);
axios.get(localeMapping[url])
.then((resp) => resp as any)
.then((resp) => callback(JSON.stringify(resp.data), resp));
},
// addPath: '/locales/{{lng}}/{{ns}}.missing.json',
};
const backendKey = 'backend';
optionsBase[backendKey] = backend;
i18n
.use(XHR)
.use(lngDetector)
.use(initReactI18next)
.init({
detection: {
order: ['catalogLangDetector'],
lookupFromPathIndex: 0,
},
react: {
useSuspense: false,
},
...optionsBase,
});
export { i18n as i18nClient };
// @ts-ignore
import i18n from 'i18next';
import * as path from 'path';
import optionsBase from '@grin-global/i18n/options-base';
import Backend from 'i18next-sync-fs-backend';
import { initReactI18next } from 'react-i18next';
const i18nextMiddleware = require('i18next-express-middleware'); // has no proper import yet
const backend = {
loadPath: path.resolve('../assets/locales/{{lng}}/{{ns}}.json'),
// addPath: path.resolve('../assets/locales/{{lng}}/{{ns}}.missing.json'),
};
const backendKey = 'backend';
optionsBase[backendKey] = backend;
i18n
.use(Backend)
.use(i18nextMiddleware.LanguageDetector)
.use(initReactI18next)
.init({
detection: {
order: ['path'],
lookupFromPathIndex: 0,
},
react: {
useSuspense: false,
},
...optionsBase,
});
export {i18n as i18nServer} ;
import detectLocaleFromPath from '@grin-global/i18n/detectLocaleFromPath';
export default {
name: 'catalogLangDetector',
lookup: function lookup(options) {
let found;
if (typeof window !== 'undefined') {
// copied from client.tsx
const virtualPath = document.baseURI.replace(/^(https?:\/\/[^\/]+)?(.*)\/$/, '$2');
found = detectLocaleFromPath(virtualPath, window.location.pathname, options.lookupFromPathIndex);
}
return found;
},
};
const optionsBase = {
fallbackLng: 'en',
preload: ['en'],
load: 'languageOnly' as 'languageOnly', // we only provide en, de -> no region specific locals like en-US, de-DE
// have a common namespace used around the full app
ns: ['translations', 'common'],
defaultNS: 'translations',
nsSeparator: ':', // namespace separator
keySeparator: '.', // key separator
saveMissing: false,
debug: false,
// cache: {
// enabled: true
// },
interpolation: {
escapeValue: false, // not needed for react!!
prefix: '{{',
suffix: '}}',
formatSeparator: ',',
format: (value, format, lng) => {
if (!value) {
return value;
} else if (format === 'uppercase') {
return value.toUpperCase();
} else if (format === 'lowercase') {
return value.toLowerCase();
} else if (format === 'number') {
return value && value.toLocaleString();
}
return value;
},
},
};
export default optionsBase;
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": [
"*",
"src/*",
"server/*",
"node_modules/*"
],
"@grin-global/ui-core/*": [
"../ui-core/*",
"../ui-core/src/*"
],
"@grin-global/i18n/*": [
"../i18n/src/*"
]
},
"outDir": "../../target",
"module": "esnext",
"target": "esnext",
"sourceMap": false,
"inlineSourceMap": true,
"experimentalDecorators": false,
"noUnusedParameters": false,
"noUnusedLocals": true,
"jsx": "react",
"moduleResolution": "node",
"importHelpers": true,
"types": [
"node",
"webpack-env"
]
},
"exclude": [
"node_modules",
"build",
"typings/main",
"typings/main.d.ts"
]
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment