305 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
			
		
		
	
	
			305 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
| #!/usr/bin/env node
 | |
| 
 | |
| /*
 | |
| Copyright 2017 New Vector Ltd
 | |
| 
 | |
| Licensed under the Apache License, Version 2.0 (the "License");
 | |
| you may not use this file except in compliance with the License.
 | |
| You may obtain a copy of the License at
 | |
| 
 | |
|     http://www.apache.org/licenses/LICENSE-2.0
 | |
| 
 | |
| Unless required by applicable law or agreed to in writing, software
 | |
| distributed under the License is distributed on an "AS IS" BASIS,
 | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| See the License for the specific language governing permissions and
 | |
| limitations under the License.
 | |
| */
 | |
| 
 | |
| /**
 | |
|  * Regenerates the translations en_EN file by walking the source tree and
 | |
|  * parsing each file with the appropriate parser. Emits a JSON file with the
 | |
|  * translatable strings mapped to themselves in the order they appeared
 | |
|  * in the files and grouped by the file they appeared in.
 | |
|  *
 | |
|  * Usage: node scripts/gen-i18n.js
 | |
|  */
 | |
| const fs = require('fs');
 | |
| const path = require('path');
 | |
| 
 | |
| const walk = require('walk');
 | |
| 
 | |
| const parser = require("@babel/parser");
 | |
| const traverse = require("@babel/traverse");
 | |
| 
 | |
| const TRANSLATIONS_FUNCS = ['_t', '_td'];
 | |
| 
 | |
| const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json';
 | |
| const OUTPUT_FILE = 'src/i18n/strings/en_EN.json';
 | |
| 
 | |
| // NB. The sync version of walk is broken for single files so we walk
 | |
| // all of res rather than just res/home.html.
 | |
| // https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it,
 | |
| // or if we get bored waiting for it to be merged, we could switch
 | |
| // to a project that's actively maintained.
 | |
| const SEARCH_PATHS = ['src', 'res'];
 | |
| 
 | |
| function getObjectValue(obj, key) {
 | |
|     for (const prop of obj.properties) {
 | |
|         if (prop.key.type === 'Identifier' && prop.key.name === key) {
 | |
|             return prop.value;
 | |
|         }
 | |
|     }
 | |
|     return null;
 | |
| }
 | |
| 
 | |
| function getTKey(arg) {
 | |
|     if (arg.type === 'Literal' || arg.type === "StringLiteral") {
 | |
|         return arg.value;
 | |
|     } else if (arg.type === 'BinaryExpression' && arg.operator === '+') {
 | |
|         return getTKey(arg.left) + getTKey(arg.right);
 | |
|     } else if (arg.type === 'TemplateLiteral') {
 | |
|         return arg.quasis.map((q) => {
 | |
|             return q.value.raw;
 | |
|         }).join('');
 | |
|     }
 | |
|     return null;
 | |
| }
 | |
| 
 | |
| function getFormatStrings(str) {
 | |
|     // Match anything that starts with %
 | |
|     // We could make a regex that matched the full placeholder, but this
 | |
|     // would just not match invalid placeholders and so wouldn't help us
 | |
|     // detect the invalid ones.
 | |
|     // Also note that for simplicity, this just matches a % character and then
 | |
|     // anything up to the next % character (or a single %, or end of string).
 | |
|     const formatStringRe = /%([^%]+|%|$)/g;
 | |
|     const formatStrings = new Set();
 | |
| 
 | |
|     let match;
 | |
|     while ( (match = formatStringRe.exec(str)) !== null ) {
 | |
|         const placeholder = match[1]; // Minus the leading '%'
 | |
|         if (placeholder === '%') continue; // Literal % is %%
 | |
| 
 | |
|         const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/);
 | |
|         if (placeholderMatch === null) {
 | |
|             throw new Error("Invalid format specifier: '"+match[0]+"'");
 | |
|         }
 | |
|         if (placeholderMatch.length < 3) {
 | |
|             throw new Error("Malformed format specifier");
 | |
|         }
 | |
|         const placeholderName = placeholderMatch[1];
 | |
|         const placeholderFormat = placeholderMatch[2];
 | |
| 
 | |
|         if (placeholderFormat !== 's') {
 | |
|             throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`);
 | |
|         }
 | |
| 
 | |
|         formatStrings.add(placeholderName);
 | |
|     }
 | |
| 
 | |
|     return formatStrings;
 | |
| }
 | |
| 
 | |
| function getTranslationsJs(file) {
 | |
|     const contents = fs.readFileSync(file, { encoding: 'utf8' });
 | |
| 
 | |
|     const trs = new Set();
 | |
| 
 | |
|     try {
 | |
|         const plugins = [
 | |
|             // https://babeljs.io/docs/en/babel-parser#plugins
 | |
|             "classProperties",
 | |
|             "objectRestSpread",
 | |
|             "throwExpressions",
 | |
|             "exportDefaultFrom",
 | |
|             "decorators-legacy",
 | |
|         ];
 | |
| 
 | |
|         if (file.endsWith(".js") || file.endsWith(".jsx")) {
 | |
|             // all JS is assumed to be flow or react
 | |
|             plugins.push("flow", "jsx");
 | |
|         } else if (file.endsWith(".ts")) {
 | |
|             // TS can't use JSX unless it's a TSX file (otherwise angle casts fail)
 | |
|             plugins.push("typescript");
 | |
|         } else if (file.endsWith(".tsx")) {
 | |
|             // When the file is a TSX file though, enable JSX parsing
 | |
|             plugins.push("typescript", "jsx");
 | |
|         }
 | |
| 
 | |
|         const babelParsed = parser.parse(contents, {
 | |
|             allowImportExportEverywhere: true,
 | |
|             errorRecovery: true,
 | |
|             sourceFilename: file,
 | |
|             tokens: true,
 | |
|             plugins,
 | |
|         });
 | |
|         traverse.default(babelParsed, {
 | |
|             enter: (p) => {
 | |
|                 const node = p.node;
 | |
|                 if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) {
 | |
|                     const tKey = getTKey(node.arguments[0]);
 | |
| 
 | |
|                     // This happens whenever we call _t with non-literals (ie. whenever we've
 | |
|                     // had to use a _td to compensate) so is expected.
 | |
|                     if (tKey === null) return;
 | |
| 
 | |
|                     // check the format string against the args
 | |
|                     // We only check _t: _td has no args
 | |
|                     if (node.callee.name === '_t') {
 | |
|                         try {
 | |
|                             const placeholders = getFormatStrings(tKey);
 | |
|                             for (const placeholder of placeholders) {
 | |
|                                 if (node.arguments.length < 2) {
 | |
|                                     throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`);
 | |
|                                 }
 | |
|                                 const value = getObjectValue(node.arguments[1], placeholder);
 | |
|                                 if (value === null) {
 | |
|                                     throw new Error(`No value found for placeholder '${placeholder}'`);
 | |
|                                 }
 | |
|                             }
 | |
| 
 | |
|                             // Validate tag replacements
 | |
|                             if (node.arguments.length > 2) {
 | |
|                                 const tagMap = node.arguments[2];
 | |
|                                 for (const prop of tagMap.properties || []) {
 | |
|                                     if (prop.key.type === 'Literal') {
 | |
|                                         const tag = prop.key.value;
 | |
|                                         // RegExp same as in src/languageHandler.js
 | |
|                                         const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`);
 | |
|                                         if (!tKey.match(regexp)) {
 | |
|                                             throw new Error(`No match for ${regexp} in ${tKey}`);
 | |
|                                         }
 | |
|                                     }
 | |
|                                 }
 | |
|                             }
 | |
| 
 | |
|                         } catch (e) {
 | |
|                             console.log();
 | |
|                             console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`);
 | |
|                             console.error(e);
 | |
|                             process.exit(1);
 | |
|                         }
 | |
|                     }
 | |
| 
 | |
|                     let isPlural = false;
 | |
|                     if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') {
 | |
|                         const countVal = getObjectValue(node.arguments[1], 'count');
 | |
|                         if (countVal) {
 | |
|                             isPlural = true;
 | |
|                         }
 | |
|                     }
 | |
| 
 | |
|                     if (isPlural) {
 | |
|                         trs.add(tKey + "|other");
 | |
|                         const plurals = enPlurals[tKey];
 | |
|                         if (plurals) {
 | |
|                             for (const pluralType of Object.keys(plurals)) {
 | |
|                                 trs.add(tKey + "|" + pluralType);
 | |
|                             }
 | |
|                         }
 | |
|                     } else {
 | |
|                         trs.add(tKey);
 | |
|                     }
 | |
|                 }
 | |
|             },
 | |
|         });
 | |
|     } catch (e) {
 | |
|         console.error(e);
 | |
|         process.exit(1);
 | |
|     }
 | |
| 
 | |
|     return trs;
 | |
| }
 | |
| 
 | |
| function getTranslationsOther(file) {
 | |
|     const contents = fs.readFileSync(file, { encoding: 'utf8' });
 | |
| 
 | |
|     const trs = new Set();
 | |
| 
 | |
|     // Taken from element-web src/components/structures/HomePage.js
 | |
|     const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg;
 | |
|     let matches;
 | |
|     while (matches = translationsRegex.exec(contents)) {
 | |
|         trs.add(matches[1]);
 | |
|     }
 | |
|     return trs;
 | |
| }
 | |
| 
 | |
| // gather en_EN plural strings from the input translations file:
 | |
| // the en_EN strings are all in the source with the exception of
 | |
| // pluralised strings, which we need to pull in from elsewhere.
 | |
| const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' }));
 | |
| const enPlurals = {};
 | |
| 
 | |
| for (const key of Object.keys(inputTranslationsRaw)) {
 | |
|     const parts = key.split("|");
 | |
|     if (parts.length > 1) {
 | |
|         const plurals = enPlurals[parts[0]] || {};
 | |
|         plurals[parts[1]] = inputTranslationsRaw[key];
 | |
|         enPlurals[parts[0]] = plurals;
 | |
|     }
 | |
| }
 | |
| 
 | |
| const translatables = new Set();
 | |
| 
 | |
| const walkOpts = {
 | |
|     listeners: {
 | |
|         names: function(root, nodeNamesArray) {
 | |
|             // Sort the names case insensitively and alphabetically to
 | |
|             // maintain some sense of order between the different strings.
 | |
|             nodeNamesArray.sort((a, b) => {
 | |
|                 a = a.toLowerCase();
 | |
|                 b = b.toLowerCase();
 | |
|                 if (a > b) return 1;
 | |
|                 if (a < b) return -1;
 | |
|                 return 0;
 | |
|             });
 | |
|         },
 | |
|         file: function(root, fileStats, next) {
 | |
|             const fullPath = path.join(root, fileStats.name);
 | |
| 
 | |
|             let trs;
 | |
|             if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) {
 | |
|                 trs = getTranslationsJs(fullPath);
 | |
|             } else if (fileStats.name.endsWith('.html')) {
 | |
|                 trs = getTranslationsOther(fullPath);
 | |
|             } else {
 | |
|                 return;
 | |
|             }
 | |
|             console.log(`${fullPath} (${trs.size} strings)`);
 | |
|             for (const tr of trs.values()) {
 | |
|                 // Convert DOS line endings to unix
 | |
|                 translatables.add(tr.replace(/\r\n/g, "\n"));
 | |
|             }
 | |
|         },
 | |
|     }
 | |
| };
 | |
| 
 | |
| for (const path of SEARCH_PATHS) {
 | |
|     if (fs.existsSync(path)) {
 | |
|         walk.walkSync(path, walkOpts);
 | |
|     }
 | |
| }
 | |
| 
 | |
| const trObj = {};
 | |
| for (const tr of translatables) {
 | |
|     if (tr.includes("|")) {
 | |
|         if (inputTranslationsRaw[tr]) {
 | |
|             trObj[tr] = inputTranslationsRaw[tr];
 | |
|         } else {
 | |
|             trObj[tr] = tr.split("|")[0];
 | |
|         }
 | |
|     } else {
 | |
|         trObj[tr] = tr;
 | |
|     }
 | |
| }
 | |
| 
 | |
| fs.writeFileSync(
 | |
|     OUTPUT_FILE,
 | |
|     JSON.stringify(trObj, translatables.values(), 4) + "\n"
 | |
| );
 | |
| 
 | |
| console.log();
 | |
| console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`);
 |