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

Internationalization #454

Merged
merged 18 commits into from
Feb 7, 2023
Merged
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
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,7 @@ module.exports = {
"/dependencies",
// Ignore tests
"/test",
// Ignore i18n
"/i18n",
],
};
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ jobs:
# See https://github.com/yarnpkg/yarn/issues/7212
run: yarn install --cache-folder ./.yarncache
working-directory: simulator
- name: Build i18n
run: yarn run build-i18n
working-directory: simulator
- name: Build production bundle
run: yarn build
working-directory: simulator
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ EXPOSE 3000
# WORKDIR /app/simulator
# RUN yarn install --cache-folder ./.yarncache && yarn build; true
RUN yarn install --cache-folder ./.yarncache
RUN yarn run build-i18n
RUN export NODE_OPTIONS=--openssl-legacy-provider && yarn build
CMD node express.js
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ Navigate to the root directory of this repository, then run:
yarn install
```

## Build Translations
```bash
yarn run build-i18n
```

# Running

In one terminal, build in watch mode:
Expand Down Expand Up @@ -119,6 +124,29 @@ The project is set up with [ESLint](https://eslint.org/) for JavaScript/TypeScri

To ease development, we highly recommend enabling ESLint within your editor so you can see issues in real time. If you're using Visual Studio Code, you can use the [VS Code ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). For other editors, see [available ESLint integrations](https://eslint.org/docs/user-guide/integrations).

## Internationalization (i18n)
bmcdorman marked this conversation as resolved.
Show resolved Hide resolved

Simulator leverages gettext PO files to create a `i18n.json` file located in `/i18n/i18n.json`. The source files are scanned for imports of `@i18n`, and uses of the default exported function (`tr` by convention) are detected and inserted into PO files located in `/i18n/po/`. These PO files are "built" into the JSON file, which is then injected into the frontend via webpack's `DefinePlugin` (see `configs/webpack/common.js` for details). The `tr` function reads this object at runtime to make translations available.

To update the PO files, run `yarn run generate-i18n`. While the generation script *should* preserve your work-in-progress, it is recommended to commit, stash, or otherwise backup the PO files prior to running this script.
navzam marked this conversation as resolved.
Show resolved Hide resolved

To build the PO files into a JSON object suitable for consumption by the frontend, run `yarn run build-i18n`.

There are many editors available for PO files. We recommend [Poedit](https://poedit.net/).

The format of the `i18n.json` is as follows:
```json
{
"context1": {
"See `src/util/LocalizedString.ts` for a list of available language identifiers": {
"en-US": "See `src/util/LocalizedString.ts` for a list of available language identifiers",
"ja-JP": "利用可能な言語識別子のリストについては、「src/util/LocalizedString.ts」を参照してください"
}
},
"..."
}
```

# Building image

The repo includes a `Dockerfile` for building a Docker image of the simulator:
Expand Down
14 changes: 14 additions & 0 deletions configs/webpack/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { readFileSync } = require('fs');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const NpmDtsPlugin = require('npm-dts-webpack-plugin')
const { DefinePlugin, IgnorePlugin } = require('webpack');
const process = require('process');

const commitHash = require('child_process').execSync('git rev-parse --short=8 HEAD').toString().trim();

Expand All @@ -23,6 +24,15 @@ if (dependencies.libkipr_c_documentation) {
libkiprCDocumentation = JSON.parse(readFileSync(resolve(dependencies.libkipr_c_documentation)));
}

let i18n = {};
try {
i18n = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'i18n', 'i18n.json')));
} catch (e) {
console.log('Failed to read i18n.json');
bmcdorman marked this conversation as resolved.
Show resolved Hide resolved
console.log(`Please run 'yarn run build-i18n'`);
process.exit(1);
}


module.exports = {
entry: {
Expand Down Expand Up @@ -53,6 +63,9 @@ module.exports = {
fs: false,
path: false,
},
alias: {
'@i18n': resolve(__dirname, '../../src/i18n'),
},
symlinks: false,
modules
},
Expand Down Expand Up @@ -131,6 +144,7 @@ module.exports = {
SIMULATOR_HAS_CPYTHON: JSON.stringify(dependencies.cpython !== undefined),
SIMULATOR_HAS_AMMO: JSON.stringify(dependencies.ammo !== undefined),
SIMULATOR_LIBKIPR_C_DOCUMENTATION: JSON.stringify(libkiprCDocumentation),
SIMULATOR_I18N: JSON.stringify(i18n),
navzam marked this conversation as resolved.
Show resolved Hide resolved
}),
new NpmDtsPlugin({
root: resolve(__dirname, '../../'),
Expand Down
1 change: 1 addition & 0 deletions i18n/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
i18n.json
34 changes: 34 additions & 0 deletions i18n/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as gettextParser from 'gettext-parser';
import * as fs from 'fs';
import * as path from 'path';
import { walkDir } from './util';
import { PO_PATH } from './po';
import { I18n } from '../src/i18n';
import LocalizedString from '../src/util/LocalizedString';


const ret: I18n = {};

walkDir(PO_PATH, file => {
if (!file.endsWith('.po')) return;

const po = gettextParser.po.parse(fs.readFileSync(file, 'utf8'));
const lang = path.basename(file, '.po');
for (const context in po.translations) {
if (!ret[context]) ret[context] = {};

const contextTranslations = po.translations[context];
for (const enUs in contextTranslations) {
if (enUs.length === 0) continue;
const translation = contextTranslations[enUs];
if (translation.msgstr[0].length === 0 && lang !== LocalizedString.EN_US) continue;
ret[context][enUs] = {
...ret[context][enUs] || {},
[lang]: translation.msgstr[0],
[LocalizedString.EN_US]: enUs,
};
}
}
});

fs.writeFileSync(path.resolve(__dirname, 'i18n.json'), JSON.stringify(ret, null, 2));
109 changes: 109 additions & 0 deletions i18n/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as fs from 'fs';
import * as path from 'path';
import * as typescript from 'typescript';
import * as gettextParser from 'gettext-parser';
import LocalizedString from '../src/util/LocalizedString';
import { DEFAULT_PO, PO_PATH } from './po';
import { walkDir } from './util';

const tsConfigPath = path.resolve(__dirname, '..', 'tsconfig.json');
const tsConfig = typescript.readConfigFile(tsConfigPath, (path) => fs.readFileSync(path, 'utf8'));

const compilerOptions = tsConfig.config.compilerOptions as typescript.CompilerOptions;

// TypeScript complains about moduleResolution being "node".
// We don't need it, so we can just delete it.
delete compilerOptions.moduleResolution;

const rootNames = [];

walkDir(path.resolve(__dirname, '..', 'src'), file => {
if (file.endsWith('.tsx') || file.endsWith('.ts')) rootNames.push(file);
});

const program = typescript.createProgram({
rootNames: rootNames,
options: compilerOptions,
});

const sourceFiles = program.getSourceFiles();

interface Tr {
enUs: string;
description?: string;
}

const findTrs = (funcName: string, sourceFile: typescript.SourceFile, node: typescript.Node) => {
let ret: Tr[] = [];

if (typescript.isCallExpression(node)) {
const expression = node.expression;
if (typescript.isIdentifier(expression)) {
if (expression.text === funcName) {
const enUs = node.arguments[0];
if (typescript.isStringLiteral(enUs)) {
const description = node.arguments[1];
if (description !== undefined && typescript.isStringLiteral(description)) {
ret.push({ enUs: enUs.text, description: description.text });
} else {
ret.push({ enUs: enUs.text });
}
}
}
}
}

const children = node.getChildren(sourceFile);
for (const child of children) ret = [...ret, ...findTrs(funcName, sourceFile, child)];
return ret;
};



const trDict: { [locale in LocalizedString.Language]?: gettextParser.GetTextTranslations } = {};

// Load existing PO files


for (const language of LocalizedString.SUPPORTED_LANGUAGES) {
const poPath = path.resolve(PO_PATH, `${language}.po`);
if (!fs.existsSync(poPath)) continue;
if (language === LocalizedString.AR_SA) console.log(fs.readFileSync(poPath, 'utf8'));
const po = gettextParser.po.parse(fs.readFileSync(poPath, 'utf8'));
trDict[language] = po;
if (language === LocalizedString.AR_SA) console.log(`Loaded ${language}.po`, JSON.stringify(po, null, 2));
}

for (const sourceFile of sourceFiles) {
const trs = findTrs('tr', sourceFile, sourceFile);
for (const language of LocalizedString.SUPPORTED_LANGUAGES) {
const po: gettextParser.GetTextTranslations = trDict[language] || DEFAULT_PO;
trDict[language] = po;
for (const tr of trs) {
const { enUs, description } = tr;

const context = description || '';
if (!po.translations) po.translations = {};
if (!po.translations[context]) po.translations[context] = {};
const translation = po.translations[context][enUs];
if (translation) continue;

if (context.length > 0) console.log(`Adding ${language} translation for "${enUs}" (context: "${context}")`);

po.translations[context][enUs] = {
msgid: enUs,
msgctxt: context,
msgstr: [''],
};

}
}
}

// Write the PO files
for (const language of LocalizedString.SUPPORTED_LANGUAGES) {
const poPath = path.resolve(PO_PATH, `${language}.po`);
const po = trDict[language];
if (!po) continue;
fs.writeFileSync(poPath, gettextParser.po.compile(po), 'utf8');
}
10 changes: 10 additions & 0 deletions i18n/po.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as path from 'path';
import * as gettextParser from 'gettext-parser';

export const PO_PATH = path.resolve(__dirname, 'po');

export const DEFAULT_PO: gettextParser.GetTextTranslations = {
charset: 'utf-8',
headers: {},
translations: {}
};
Loading