Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add translation scripts #193

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,14 @@ Run end-to-end tests:
- Development build: `npm run dev`
- Production build: `npm run build && npm run test:e2e`

## Translations

Translations reside in `public/translations`. Each language is represented by a JSON file, where the file name is the language’s [ISO 639 alpha-2 code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes). Each file consists of a single object of key-value pairs; the key is the English string, the value is the translation. The first string of each file with key `$language` contains its native name. There are a few other special keys starting with `$`; while all other keys are to be translated literally, these keys serve as placeholders for longer sections of text. Search the source files for these keys to reveal their corresponding English texts.

To create a new empty translation, run `node build/create-translation.js` and follow the prompts.

To check all translations for validity and completeness, use `npm run test:i18n` or `npm run test:i18n:fix`, the latter adding missing keys, removing unused keys, and sorting keys.

---

<a href="https://www.sub.uni-goettingen.de/en/">
Expand Down
55 changes: 55 additions & 0 deletions build/create-translation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import fs from 'fs';
import readline from 'readline';

import chalk from 'chalk';

import {
findTranslatedStrings,
indention,
rootDir,
} from './i18n.js';

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let langCode = await new Promise((resolve) => {
rl.question('Two-letter language code (ISO 639-1): ', resolve);
});

langCode = langCode.toLowerCase();

if (!langCode.match(/^[a-z]{2}$/)) {
console.warn(chalk.redBright('Invalid language code'));
process.exit(1);
}

const fileName = `${rootDir}public/translations/${langCode}.json`;

if (fs.existsSync(fileName)) {
console.warn(chalk.redBright(`${chalk.bold(fileName)} already exists`));
process.exit(1);
}

let language = await new Promise((resolve) => {
rl.question('Native language name: ', resolve);
});

rl.close();

language = language.charAt(0).toUpperCase() + language.slice(1).toLowerCase();
t11r marked this conversation as resolved.
Show resolved Hide resolved

const translationObject = {
$language: language,
};

findTranslatedStrings(`${rootDir}/src`, '\\$translate').forEach((item) => {
translationObject[item.key] = '';
});

const json = JSON.stringify(translationObject, null, indention);

fs.writeFileSync(fileName, `${json}\n`);

console.log(`\nCreated ${chalk.dim('file://')}${chalk.bold(fileName)}`);
223 changes: 223 additions & 0 deletions build/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import fs from 'fs';
import path from 'path';
import url from 'url';

import chalk from 'chalk';
import * as Diff from 'diff';

export const indention = '\t';

export const rootDir = url.fileURLToPath(new URL('..', import.meta.url));

function sortTranslation(translation) {
return Object.keys(translation)
.sort((a, b) => {
if (a === '$language') {
return -1;
}

if (b === '$language') {
return 1;
}

return a.toLowerCase().localeCompare(b.toLowerCase());
})
.reduce((acc, key) => {
acc[key] = translation[key];
return acc;
}, {});
}

export function checkTranslationFiles(
translationsDir,
referenceKeys,
options = { addMissing: false, removeUnused: false, sort: false },
) {
const translationFiles = fs.readdirSync(translationsDir);

const results = [];
translationFiles.forEach((file) => {
const lang = path.parse(file).name;

const result = {
lang,
notes: [],
issues: [],
};
t11r marked this conversation as resolved.
Show resolved Hide resolved

const json = fs.readFileSync(`${translationsDir}/${file}`);
const translation = JSON.parse(json);
const keys = Object.keys(translation);

const emptyKeys = keys.filter((key) => !translation[key]);
result.issues.push(...emptyKeys.map((key) => ({ type: 'empty', key })));

const missingKeys = referenceKeys.filter((key) => !keys.includes(key));
result.issues.push(...missingKeys.map((key) => ({ type: 'missing', key })));

const unusedKeys = keys.filter((key) => !referenceKeys.includes(key));
result.issues.push(...unusedKeys.map((key) => ({ type: 'unused', key })));

const keysStr = keys.join(', ');
const referenceKeysStr = referenceKeys.join(', ');
if (!missingKeys.length
&& !unusedKeys.length
&& keysStr !== referenceKeysStr
) {
const diffs = Diff.diffChars(keysStr, referenceKeysStr);

const difference = diffs.map((part) => {
if (part.added) {
return chalk.green(part.value);
}

if (part.removed) {
return chalk.red(part.value);
}

return part.value;
}).join('');

result.notes.push(`Keys are not properly sorted: ${chalk.reset(difference)}`);
}

results.push(result);

if (options.addMissing && missingKeys.length) {
missingKeys.forEach((key) => {
translation[key] = '';
});
result.notes.push('Added missing keys, please add translations');
}

if (options.removeUnused && unusedKeys.length) {
unusedKeys.forEach((key) => {
delete translation[key];
});
result.notes.push('Removed unused keys');
}

const updatedTranslation = options.sort
? sortTranslation(translation)
: translation;

if (options.addMissing || options.removeUnused || options.sort) {
const updatedJson = JSON.stringify(updatedTranslation, null, indention);
fs.writeFileSync(`${translationsDir}/${file}`, `${updatedJson}\n`);
}
});

return results;
}

/**
* Find all proper occurrences of $translate()
*
* $translate() should always fit into a single line and only have single-quoted
* strings as parameters. The function params may contain ternary operators, but
* translated strings must never be concatenated or otherwise "built" for the
* automated translation tests to work.
*
* Good examples:
*
* $translate('String');
* $translate('String', 'Fallback string');
* $translate(first ? 'String' : 'Other string');
*
* Bad examples (i.e. would not be found):
*
* $translate('String' + ' ' + 'another string');
* $translate(
* 'String',
* );
* $translate(1);
* $translate(
* '1',
* `2`,
* );
* $translate('',
* '2',
* );
*/
export function findTranslatedStrings(dir, functionName, fileName = '\\.(js|vue)$') {
const files = fs.readdirSync(dir, { withFileTypes: true });
const results = [];

const translationFunctionRegexp = new RegExp(`${functionName}\\s*\\(\\s*(.*)\\s*\\)?`, 'gs');
const fileNameRegexp = new RegExp(fileName);

files.forEach((file) => {
const filePath = path.join(dir, file.name);

if (file.isDirectory()) {
const dirResults = findTranslatedStrings(filePath, functionName);
results.push(...dirResults);
return;
}

if (!file.name.match(fileNameRegexp)) {
return;
}

let lineNumber = 1;

const fileContents = fs.readFileSync(filePath, 'utf8');

fileContents.split('\n').forEach((line) => {
const matches = [...line.matchAll(translationFunctionRegexp)];

if (!matches) {
return;
}

matches.forEach((match) => {
const strings = match[1]
.match(/'(.*?)'|"(.*?)"|`(.*?)`/g)
?.map((string) => string.slice(1, -1)); // remove quotes

if (!strings) {
console.warn(chalk.redBright(
'Found translation function, but could not determine parameters'
+ ` in ${chalk.dim('file://')}${chalk.bold(filePath)}, line ${lineNumber}: ${chalk.bold(match[0])}\n`,
));

return;
}

if (strings.some(((string) => !string))) {
console.warn(chalk.redBright(
'Translation function uses an empty value'
+ ` in ${chalk.dim('file://')}${chalk.bold(filePath)}, line ${lineNumber}: ${chalk.bold(match[0])}\n`,
));

return;
}

const matchObject = {
file: filePath,
line: lineNumber,
match: match[0],
};

strings.forEach((string) => {
const index = results.findIndex((result) => result.key === string);

if (index >= 0) {
results[index].matches.push(matchObject);
} else {
results.push({
key: string,
matches: [matchObject],
});
}
});
});

lineNumber += 1;
});
});

results.sort((a, b) => a.key.localeCompare(b.key));

return results;
}
62 changes: 62 additions & 0 deletions build/test-translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import chalk from 'chalk';

import {
checkTranslationFiles,
findTranslatedStrings,
rootDir,
} from './i18n.js';

const translatedStrings = findTranslatedStrings(`${rootDir}/src`, '\\$translate')
.map((result) => result.key);

if (!translatedStrings.length) {
console.log('No translated strings found');
process.exit(1);
}

// TODO: Alert on missing language name
translatedStrings.unshift('$language');

const options = {
addMissing: process.argv.includes('--add'),
removeUnused: process.argv.includes('--remove'),
sort: process.argv.includes('--sort'),
};

const translationsDir = `${rootDir}public/translations`;
const results = checkTranslationFiles(translationsDir, translatedStrings, options);

let translationsWithIssuesCount = 0;

results.forEach((result) => {
console.log(`${chalk.dim('file://')}${translationsDir}/${chalk.bold(result.lang)}.json`);

['empty', 'missing', 'unused'].forEach((type) => {
const issues = result.issues.filter((issue) => issue.type === type);
const label = `${type.charAt(0).toUpperCase() + type.slice(1)} keys`;
if (issues.length) {
console.log(` ${chalk.redBright(label)}`);
console.log(` ${chalk.red(issues.map((issue) => issue.key).join('\n '))}`);
}
});

result.notes.forEach((note) => {
console.log(` ${chalk.cyanBright(note)}`);
});

if (result.notes.length || result.issues.length) {
translationsWithIssuesCount += 1;
} else {
console.log(chalk.greenBright(' Shiny!'));
}

console.log();
});

console.log(`Checked ${results.length} languages, ${
translationsWithIssuesCount
? chalk.redBright(`found issues with ${translationsWithIssuesCount}.`)
: chalk.greenBright('found no issues.')
}`);

process.exit(translationsWithIssuesCount ? 1 : 0);
Loading
Loading