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

[WIP] Use Crowdin translations for reactjs.org #873

Closed
wants to merge 40 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
19b8576
Add translation download script
Nov 10, 2017
05ed572
Merged master
bvaughn Nov 10, 2017
7f72e0d
Initial iteration on localization stuff
bvaughn Nov 10, 2017
c97a8cb
Iterated more on Crowdin plug-in.
bvaughn Nov 11, 2017
ed5e326
Iterating on Gatsby+Crowdin plug-in a bit more
bvaughn Nov 12, 2017
d426e71
Update script to filter locales with certain progress thresholds and …
Nov 10, 2017
0ac2387
Update download script to symlink downloaded translations
Nov 11, 2017
3801c2b
Update cleanup script
Nov 11, 2017
5efa663
Refactor crowdin download script
Nov 12, 2017
aee9bdc
Make script runnable again
Nov 13, 2017
fc1d65c
Change allowable language threshold to 50%
Nov 13, 2017
06f68ce
Add yarn script to download/symlink translations
Nov 17, 2017
4b6e332
Prettier
May 10, 2018
5f2ef48
Merged master
May 10, 2018
70b9bd5
Added config validation and fixed language <> locale mappin
May 11, 2018
31381a8
Iterating on Crowdin scripts, Link, pages, etc
May 11, 2018
40d6768
Reorganized Crowdin folders for readability
May 11, 2018
52a0f6a
Changing Crowdin symlink directory structure.
May 15, 2018
1fb5ffe
Improved some conditionals
May 15, 2018
ca3fd11
Added en-US default symlink
May 15, 2018
a0f5689
Variable typo
May 15, 2018
3737457
Made language code parsing more robust
May 15, 2018
425af19
Updated crowdin/README.md
May 15, 2018
926500e
Fixed docs permalink redirects to be language-aware
May 15, 2018
b0bd040
Added Node debug command to package.json for convenience
May 15, 2018
86f5126
Fixed redirects (localized and not)
May 15, 2018
2c9d7df
Default redirect pages for English only
May 15, 2018
8e77586
Initial idea for gather language data for locale switcher UI
May 15, 2018
019447c
Added stub translations page
May 16, 2018
4086ea8
Prettier and Flow fix
May 16, 2018
2cf08de
Updated ESLint ignore file
May 16, 2018
3e0073c
Tidied up reqiure statements for Crowdin scripts
May 16, 2018
753d2c7
Fixed broken error decoder route
May 16, 2018
d5211dc
Updated Crowdin README
May 16, 2018
dec99ee
JIT choose default docs language and persist user selections with loc…
May 16, 2018
e759552
Crowdin README update
May 16, 2018
8fb1b0d
Added versions and translations links to the footer (for mobile)
May 16, 2018
a15b252
Merge branch 'master' into integrate-crowdin
May 16, 2018
a444762
Fixed sidebar detection hack that broke with localized routes
May 16, 2018
a8ad83e
Added TODO
May 21, 2018
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
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ node_modules/*

# Ignore markdown files and examples
content/*
crowdin/__exported__/*
crowdin/__translated__/*
crowdin/__untranslated__/*

# Ignore built files
public/*
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.idea
node_modules
public
yarn-error.log
3 changes: 3 additions & 0 deletions crowdin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__exported__/
__translated__/
languages.json
105 changes: 105 additions & 0 deletions crowdin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
## How does it work?

**Only content from [the `content/docs` directory](https://github.com/reactjs/reactjs.org/tree/master/content/docs) is localized. All other sections/pages remain English only.**

### Downloading content from Crowdin

This directory contains some JavaScript files as well as a symlink for the default language (English) that points to [the `content/docs` directory](https://github.com/reactjs/reactjs.org/tree/master/content/docs):
```sh
.
└── crowdin
   ├── __translated__ #----------------------- Initially empty, except for English
│   └── en-US
│   └── docs -> ../../../content/docs
   ├── __untranslated__ #--------------------- Contains symlinks to untranslated content
│   ├── blog -> ../../content/blog
│   └── # ...
   ├── config.js #---------------------------- Crowdin configuration settings
   └── download.js #-------------------------- Node Download script
```

To retrieve translations using the Crowdin API, use the Yarn task `yarn crowdin:download`. This will download data into an `__exported__` subdirectory:
```sh
.
└── crowdin
   ├── __exported__
  │   └── # Crowdin expoert goes here ...
├── __translated__
  │   └── # ...
├── __untranslated__
  │   └── # ...
   └── # ...
```

Next the task identifies which languages have been translated past a certain threshold (specified by `config.js`). For these languages, the script creates symlinks in the `__translated__` subdirectory:
```sh
.
└── crowdin
├── __exported__
  │   └── # ...
├── __translated__
│   ├── en-US
│   │   └── docs -> ../../../content/docs
│   ├── es-ES
│   │   └── docs -> ../../__exported__/path/to/docs/es-ES/docs
│   └── # Other languages that pass the threshold ...
├── __untranslated__
  │   └── # ...
   └── # ...
```

### Gatsby integration

A new (local) `gatsby-plugin-crowdin` plugin has been created that knows how to create localized links to certain sections of the website (e.g. things within the translated "/docs" directory).

The `gatsby-source-filesystem` plugin has been configured to read all content from the `crowdin/__translated__/` and `crowdin/__untranslated__/` (symlinked) directories rather than `content`. This way it consumes translated content when available. (Crowdin provides default language fallbacks for pages/sections that have not yet been translated for any given locale.)

This configuration is done via `gatsby-config.js`:
```js
{
resolve: 'gatsby-source-filesystem',
options: {
name: 'untranslated',
path: `${__dirname}/crowdin/__untranslated__/`,
},
},
{
resolve: 'gatsby-source-filesystem',
options: {
name: 'translated',
path: `${__dirname}/crowdin/__translated__/`,
},
},
```

Because of the default initial symlink (`crowdin/__translated__/en-US/docs` -> `content/docs`) Gatsby will still serve English content when run locally, even if the Crowdin script has not been run. This should enable fast iteration and creation of new content.

Translations can be updated by running `yarn crowdin:download` (or automatically as part of CI deployment).

### Language selector

The Yarn task `crowdin:update-languages` determines which translated languages have been downloaded. (This task is automatically run before `yarn dev` or `yarn build` in order to just-in-time update the list.) The task writes a list of locales to a local JSON file, `languages.json`:

```sh
.
└── crowdin
   ├── __exported__
  │   └── # ...
├── __translated__
  │   └── # ...
├── __untranslated__
  │   └── # ...
├── translated-languages.json # This is the list of local translations
   └── # ...
```

This `languages.json` list is imported into a translations page (`pages/translations.js`) and used to create a list of links to translated docs.

### Locale persistence

By default, legacy links to docs pages (e.g. `/docs/hello-world.html`) are re-routed to a new page (`docs-language-redirect.js`) that determines which locale to redirect to (e.g. `/en-US/docs/hello-world.html`). This is done as follows:
* First it checks `localStorage` for the user's selected language. If one is found, it is used.
* Next it checks the user's preferred languages (using `navigator.languages`). If any have been translated, it is used.
* Lastly it falls back to English.

Each time a user visits a localized docs path, the website updates their currently selected language (in `localStorage`) so that subsequent visits (within this session or a new session) will restore their selected language.
1 change: 1 addition & 0 deletions crowdin/__translated__/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!en-US
1 change: 1 addition & 0 deletions crowdin/__translated__/en-US/docs
1 change: 1 addition & 0 deletions crowdin/__untranslated__/404.md
1 change: 1 addition & 0 deletions crowdin/__untranslated__/acknowledgements.yml
1 change: 1 addition & 0 deletions crowdin/__untranslated__/authors.yml
1 change: 1 addition & 0 deletions crowdin/__untranslated__/blog
1 change: 1 addition & 0 deletions crowdin/__untranslated__/community
1 change: 1 addition & 0 deletions crowdin/__untranslated__/home
1 change: 1 addition & 0 deletions crowdin/__untranslated__/images
1 change: 1 addition & 0 deletions crowdin/__untranslated__/tutorial
1 change: 1 addition & 0 deletions crowdin/__untranslated__/versions.yml
1 change: 1 addition & 0 deletions crowdin/__untranslated__/warnings
11 changes: 11 additions & 0 deletions crowdin/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const path = require('path');

// Also relates to the crowdin.yaml file in the root directory
module.exports = {
defaultLanguage: 'en',
downloadedRootDirectory: path.join('test-17', 'docs'),
key: process.env.CROWDIN_API_KEY,
threshold: 50,
url: 'https://api.crowdin.com/api/project/react',
whitelist: ['docs'],
};
140 changes: 140 additions & 0 deletions crowdin/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
const Crowdin = require('crowdin-node');
const {downloadedRootDirectory, key, threshold, url, whitelist} = require('./config');
const {existsSync, mkdirSync} = require('fs');
const {join, resolve} = require('path');
const {symlink, lstatSync, readdirSync} = require('fs');

const TRANSLATED_PATH = resolve(__dirname, '__translated__');
const EXPORTED_PATH = resolve(__dirname, '__exported__');

// Path to the "docs" folder within the downloaded Crowdin translations bundle.
const downloadedDocsPath = resolve(
EXPORTED_PATH,
downloadedRootDirectory,
);

// Sanity check (local) Crowdin config file for expected values.
const validateCrowdinConfig = () => {
const errors = [];
if (!key) {
errors.push('key: No process.env.CROWDIN_API_KEY value defined.');
}
if (!Number.isInteger(threshold)) {
errors.push(`threshold: Invalid translation threshold defined.`);
}
if (!downloadedRootDirectory) {
errors.push('downloadedRootDirectory: No root directory defined for the downloaded translations bundle.');
}
if (!url) {
errors.push('url: No Crowdin project URL defined.');
}
if (errors.length > 0) {
console.error('Invalid Crowdin config values for:\n• ' + errors.join('\n• '));
throw Error('Invalid Crowdin config');
}
};

// Download Crowdin translations (into EXPORTED_PATH),
// Filter languages that have been sufficiently translated (based on config.threshold),
// And setup symlinks for them (in TRANSLATED_PATH) for Gatsby to read.
const downloadAndSymlink = () => {
const crowdin = new Crowdin({apiKey: key, endpointUrl: url});
crowdin
// .export() // Not sure if this should be called in the script since it could be very slow
// .then(() => crowdin.downloadToPath(EXPORTED_PATH))
.downloadToPath(EXPORTED_PATH)
.then(() => crowdin.getTranslationStatus())
.then(locales => {
const usableLocales = locales
.filter(
locale => locale.translated_progress > threshold,
)
.map(local => local.code);

const localeDirectories = getLanguageDirectories(downloadedDocsPath);
const localeToFolderMap = createLocaleToFolderMap(localeDirectories);

usableLocales.forEach(locale => {
const languageCode = localeToFolderMap.get(locale);
const rootLanguageFolder = resolve(TRANSLATED_PATH, languageCode);

if (Array.isArray(whitelist)) {
if (!existsSync(rootLanguageFolder)) {
mkdirSync(rootLanguageFolder);
}

// Symlink only the whitelisted subdirectories
whitelist.forEach(subdirectory => {
createSymLink(join(languageCode, subdirectory));
});
} else {
// Otherwise symlink the entire language export
createSymLink(languageCode);
}
});
});

};

// Creates a relative symlink from a downloaded translation in the current working directory
// Note that the current working directory of this node process should be where the symlink is created
// or else the relative paths would be incorrect
const createSymLink = (relativePath) => {
const from = resolve(downloadedDocsPath, relativePath);
const to = resolve(TRANSLATED_PATH, relativePath);
symlink(from, to, err => {
if (!err) {
return;
}

if (err.code === 'EEXIST') {
// eslint-disable-next-line no-console
console.info(`Symlink already exists for ${to}`);
} else {
console.error(err);
process.exit(1);
}
});
};

// Crowdin.getTranslationStatus() provides ISO 639-1 (e.g. "fr" for French) or 639-3 (e.g. "fil" for Filipino) language codes,
// But the folder structure of downloaded translations uses locale codes (e.g. "fr-FR" for French, "fil-PH" for the Philippines).
// This function creates a map between language and locale code.
const createLocaleToFolderMap = (directories) => {
const localeToLanguageCode = locale => locale.includes('-') ? locale.substr(0, locale.indexOf('-')) : locale;
const localeToFolders = new Map();
const localeToFolder = new Map();

for (let locale of directories) {
const languageCode = localeToLanguageCode(locale);

localeToFolders.set(
languageCode,
localeToFolders.has(languageCode)
? localeToFolders.get(languageCode).concat(locale)
: [locale],
);
}

localeToFolders.forEach((folders, locale) => {
if (folders.length === 1) {
localeToFolder.set(locale, folders[0]);
} else {
for (let folder of folders) {
localeToFolder.set(folder, folder);
}
}
});

return localeToFolder;
};

// Parse downloaded translation folder to determine which langauges it contains.
const getLanguageDirectories = source =>
readdirSync(source).filter(
name =>
lstatSync(join(source, name)).isDirectory() && name !== '_data',
);

validateCrowdinConfig();
downloadAndSymlink();
22 changes: 22 additions & 0 deletions crowdin/update-languages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const {readdirSync, statSync, writeFileSync} = require('fs');
const {join, resolve} = require('path');

const TRANSLATED_LANGUAGES_JSON_PATH = resolve(__dirname, 'languages.json');
const TRANSLATED_PATH = resolve(__dirname, '__translated__');

// TODO Use crowdin.yaml

// Determine which languages we have translations downloaded for...
const languages = [];
readdirSync(TRANSLATED_PATH).forEach(entry => {
if (statSync(join(TRANSLATED_PATH, entry)).isDirectory()) {
languages.push(entry);
}
});

// Update the languages JSON config file.
// This file is used to display the localization toggle UI.
writeFileSync(
TRANSLATED_LANGUAGES_JSON_PATH,
JSON.stringify(languages),
);
21 changes: 18 additions & 3 deletions gatsby-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ module.exports = {
'gatsby-plugin-netlify',
'gatsby-plugin-glamor',
'gatsby-plugin-react-next',
{
resolve: 'gatsby-plugin-crowdin',
options: {},
},
'gatsby-plugin-twitter',
{
resolve: 'gatsby-plugin-nprogress',
Expand All @@ -34,15 +38,22 @@ module.exports = {
{
resolve: 'gatsby-source-filesystem',
options: {
path: `${__dirname}/src/pages`,
name: 'pages',
path: `${__dirname}/src/pages`,
},
},
{
resolve: 'gatsby-source-filesystem',
options: {
name: 'packages',
path: `${__dirname}/content/`,
name: 'untranslated',
path: `${__dirname}/crowdin/__untranslated__/`,
},
},
{
resolve: 'gatsby-source-filesystem',
options: {
name: 'translated',
path: `${__dirname}/crowdin/__translated__/`,
},
},
{
Expand Down Expand Up @@ -70,6 +81,10 @@ module.exports = {
target: '_blank',
},
},
{
resolve: 'gatsby-plugin-crowdin',
options: {},
},
{
resolve: 'gatsby-remark-embed-snippet',
options: {
Expand Down
Loading