diff --git a/.eslintignore b/.eslintignore index 9425417154d..1cc7ab1e189 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,6 +2,9 @@ node_modules/* # Ignore markdown files and examples content/* +crowdin/__exported__/* +crowdin/__translated__/* +crowdin/__untranslated__/* # Ignore built files public/* diff --git a/.gitignore b/.gitignore index dbe72d17694..afbe8974e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .idea node_modules public +yarn-error.log diff --git a/crowdin/.gitignore b/crowdin/.gitignore new file mode 100644 index 00000000000..017422d75ad --- /dev/null +++ b/crowdin/.gitignore @@ -0,0 +1,3 @@ +__exported__/ +__translated__/ +languages.json \ No newline at end of file diff --git a/crowdin/README.md b/crowdin/README.md new file mode 100644 index 00000000000..715a572dbca --- /dev/null +++ b/crowdin/README.md @@ -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. \ No newline at end of file diff --git a/crowdin/__translated__/.gitignore b/crowdin/__translated__/.gitignore new file mode 100644 index 00000000000..557915cbb4f --- /dev/null +++ b/crowdin/__translated__/.gitignore @@ -0,0 +1 @@ +!en-US \ No newline at end of file diff --git a/crowdin/__translated__/en-US/docs b/crowdin/__translated__/en-US/docs new file mode 120000 index 00000000000..532209e8bf3 --- /dev/null +++ b/crowdin/__translated__/en-US/docs @@ -0,0 +1 @@ +../../../content/docs \ No newline at end of file diff --git a/crowdin/__untranslated__/404.md b/crowdin/__untranslated__/404.md new file mode 120000 index 00000000000..ea9377974f3 --- /dev/null +++ b/crowdin/__untranslated__/404.md @@ -0,0 +1 @@ +../../content/404.md \ No newline at end of file diff --git a/crowdin/__untranslated__/acknowledgements.yml b/crowdin/__untranslated__/acknowledgements.yml new file mode 120000 index 00000000000..8a080ff1898 --- /dev/null +++ b/crowdin/__untranslated__/acknowledgements.yml @@ -0,0 +1 @@ +../../content/acknowledgements.yml \ No newline at end of file diff --git a/crowdin/__untranslated__/authors.yml b/crowdin/__untranslated__/authors.yml new file mode 120000 index 00000000000..41bd15345e1 --- /dev/null +++ b/crowdin/__untranslated__/authors.yml @@ -0,0 +1 @@ +../../content/authors.yml \ No newline at end of file diff --git a/crowdin/__untranslated__/blog b/crowdin/__untranslated__/blog new file mode 120000 index 00000000000..b88eff338de --- /dev/null +++ b/crowdin/__untranslated__/blog @@ -0,0 +1 @@ +../../content/blog \ No newline at end of file diff --git a/crowdin/__untranslated__/community b/crowdin/__untranslated__/community new file mode 120000 index 00000000000..43f0a9bbf45 --- /dev/null +++ b/crowdin/__untranslated__/community @@ -0,0 +1 @@ +../../content/community \ No newline at end of file diff --git a/crowdin/__untranslated__/home b/crowdin/__untranslated__/home new file mode 120000 index 00000000000..419e1cca147 --- /dev/null +++ b/crowdin/__untranslated__/home @@ -0,0 +1 @@ +../../content/home \ No newline at end of file diff --git a/crowdin/__untranslated__/images b/crowdin/__untranslated__/images new file mode 120000 index 00000000000..2bf9fd8e63b --- /dev/null +++ b/crowdin/__untranslated__/images @@ -0,0 +1 @@ +../../content/images \ No newline at end of file diff --git a/crowdin/__untranslated__/tutorial b/crowdin/__untranslated__/tutorial new file mode 120000 index 00000000000..9699b9f4b20 --- /dev/null +++ b/crowdin/__untranslated__/tutorial @@ -0,0 +1 @@ +../../content/tutorial \ No newline at end of file diff --git a/crowdin/__untranslated__/versions.yml b/crowdin/__untranslated__/versions.yml new file mode 120000 index 00000000000..783cccf8cd3 --- /dev/null +++ b/crowdin/__untranslated__/versions.yml @@ -0,0 +1 @@ +../../content/versions.yml \ No newline at end of file diff --git a/crowdin/__untranslated__/warnings b/crowdin/__untranslated__/warnings new file mode 120000 index 00000000000..9ef7a1dc98e --- /dev/null +++ b/crowdin/__untranslated__/warnings @@ -0,0 +1 @@ +../../content/warnings \ No newline at end of file diff --git a/crowdin/config.js b/crowdin/config.js new file mode 100644 index 00000000000..47ffabba541 --- /dev/null +++ b/crowdin/config.js @@ -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'], +}; diff --git a/crowdin/download.js b/crowdin/download.js new file mode 100644 index 00000000000..f10e9565084 --- /dev/null +++ b/crowdin/download.js @@ -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(); \ No newline at end of file diff --git a/crowdin/update-languages.js b/crowdin/update-languages.js new file mode 100644 index 00000000000..53ebda1cc6c --- /dev/null +++ b/crowdin/update-languages.js @@ -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), +); diff --git a/gatsby-config.js b/gatsby-config.js index fe4410e959d..6f604ed3c8e 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -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', @@ -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__/`, }, }, { @@ -70,6 +81,10 @@ module.exports = { target: '_blank', }, }, + { + resolve: 'gatsby-plugin-crowdin', + options: {}, + }, { resolve: 'gatsby-remark-embed-snippet', options: { diff --git a/gatsby/createPages.js b/gatsby/createPages.js index 8dd2922c4c4..b5d938c17ac 100644 --- a/gatsby/createPages.js +++ b/gatsby/createPages.js @@ -7,8 +7,10 @@ 'use strict'; const {resolve} = require('path'); +const {defaultLanguage} = require('../crowdin/config.js'); -module.exports = async ({graphql, boundActionCreators}) => { +module.exports = async (params) => { + const {graphql, boundActionCreators} = params; const {createPage, createRedirect} = boundActionCreators; // Used to detect and prevent duplicate redirects @@ -33,6 +35,10 @@ module.exports = async ({graphql, boundActionCreators}) => { edges { node { fields { + id + language + languageCode + path redirect slug } @@ -50,9 +56,10 @@ module.exports = async ({graphql, boundActionCreators}) => { } allMarkdown.data.allMarkdownRemark.edges.forEach(edge => { - const slug = edge.node.fields.slug; + const {fields} = edge.node; + let {id, language, languageCode, slug} = fields; - if (slug === 'docs/error-decoder.html') { + if (slug === '/docs/error-decoder.html') { // No-op so far as markdown templates go. // Error codes are managed by a page in src/pages // (which gets created by Gatsby during a separate phase). @@ -79,45 +86,66 @@ module.exports = async ({graphql, boundActionCreators}) => { template = tutorialTemplate; } - const createArticlePage = path => - createPage({ - path: path, - component: template, - context: { - slug, - }, - }); + const prependSlash = path => path.startsWith('/') ? path : `/${path}`; + + // TODO This should be a utility exposed by gatsby-plugin-crowdin + const localizePath = path => { + path = prependSlash(path); + if (languageCode != null) { + return `/${languageCode}${path}`; + } else { + return path; + } + }; // Register primary URL. - createArticlePage(slug); + createPage({ + path: localizePath(slug), + component: template, + context: { + id, + language, + languageCode, + }, + }); // Register redirects as well if the markdown specifies them. - if (edge.node.fields.redirect) { + if (fields.redirect) { let redirect = JSON.parse(edge.node.fields.redirect); if (!Array.isArray(redirect)) { redirect = [redirect]; } redirect.forEach(fromPath => { - if (redirectToSlugMap[fromPath] != null) { + const localizedFromPath = localizePath(fromPath); + + if (redirectToSlugMap[localizedFromPath] != null) { console.error( `Duplicate redirect detected from "${fromPath}" to:\n` + - `* ${redirectToSlugMap[fromPath]}\n` + + `* ${redirectToSlugMap[localizedFromPath]}\n` + `* ${slug}\n`, ); process.exit(1); } - // A leading "/" is required for redirects to work, - // But multiple leading "/" will break redirects. - // For more context see github.com/reactjs/reactjs.org/pull/194 - const toPath = slug.startsWith('/') ? slug : `/${slug}`; + const localizedToPath = localizePath(slug); + + redirectToSlugMap[localizedFromPath] = localizedToPath; + + if (language === defaultLanguage) { + createRedirect({ + fromPath: prependSlash(fromPath), + toPath: localizedToPath, + redirectInBrowser: true, + }); + } - redirectToSlugMap[fromPath] = slug; + // Create language-aware redirect createRedirect({ - fromPath: `/${fromPath}`, + fromPath: localizedFromPath, + toPath: localizedToPath, redirectInBrowser: true, - toPath, + Language: language, }); }); } @@ -146,6 +174,7 @@ module.exports = async ({graphql, boundActionCreators}) => { const newestBlogNode = newestBlogEntry.data.allMarkdownRemark.edges[0].node; // Blog landing page should always show the most recent blog entry. + // Note that blog content is not localized. createRedirect({ fromPath: '/blog/', redirectInBrowser: true, diff --git a/gatsby/modifyWebpackConfig.js b/gatsby/modifyWebpackConfig.js index d163571e6cf..1d2f978b776 100644 --- a/gatsby/modifyWebpackConfig.js +++ b/gatsby/modifyWebpackConfig.js @@ -6,7 +6,7 @@ 'use strict'; -const {resolve} = require('path'); +const {join, resolve} = require('path'); const webpack = require('webpack'); module.exports = ({config, stage}) => { @@ -17,6 +17,10 @@ module.exports = ({config, stage}) => { resolve: { root: resolve(__dirname, '../src'), extensions: ['', '.js', '.jsx', '.json'], + alias: { + // TODO Remove this alias (and the one below) after plug-in release. + 'gatsby-plugin-crowdin': join(__dirname, '../plugins/gatsby-plugin-crowdin'), + }, }, }); return config; diff --git a/gatsby/onCreateNode.js b/gatsby/onCreateNode.js index bc79b7a4bc1..bd67766c8ca 100644 --- a/gatsby/onCreateNode.js +++ b/gatsby/onCreateNode.js @@ -9,6 +9,8 @@ // Parse date information out of blog post filename. const BLOG_POST_FILENAME_REGEX = /([0-9]+)\-([0-9]+)\-([0-9]+)\-(.+)\.md$/; +let id = 0; + // Add custom fields to MarkdownRemark nodes. module.exports = exports.onCreateNode = ({node, boundActionCreators, getNode}) => { const {createNodeField} = boundActionCreators; @@ -49,6 +51,19 @@ module.exports = exports.onCreateNode = ({node, boundActionCreators, getNode}) = slug = `/${relativePath.replace('.md', '.html')}`; } + // Slugs are easier to process elsewhere if we ensure they always start with "/" + if (!slug.startsWith('/')) { + slug = `/${slug}`; + } + + // Unique ID for template GraphQL queries; + // This avoids potential duplicate slugs between translated content. + createNodeField({ + node, + name: 'id', + value: (++id).toString(), + }); + // Used to generate URL to view this content. createNodeField({ node, diff --git a/gatsby/onCreatePage.js b/gatsby/onCreatePage.js index a6da8a80fe1..9a85f9b9ec3 100644 --- a/gatsby/onCreatePage.js +++ b/gatsby/onCreatePage.js @@ -12,9 +12,9 @@ module.exports = async ({page, boundActionCreators}) => { return new Promise(resolvePromise => { // page.matchPath is a special key that's used for matching pages only on the client. // Explicitly wire up all error code wildcard matches to redirect to the error code page. - if (page.path.includes('docs/error-decoder.html')) { - page.matchPath = 'docs/error-decoder:path?'; - page.context.slug = 'docs/error-decoder.html'; + if (page.path.includes('/docs/error-decoder.html')) { + page.matchPath = '/docs/error-decoder:path?'; + page.context.slug = '/docs/error-decoder.html'; createPage(page); } diff --git a/package.json b/package.json index 9ec03382ac1..0114c79e69d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "array-from": "^2.1.1", "babel-eslint": "^8.0.1", + "crowdin-node": "^1.0.0", "eslint": "^4.8.0", "eslint-config-fbjs": "^2.0.0", "eslint-config-react": "^1.1.7", @@ -78,17 +79,25 @@ "build": "gatsby build", "check-all": "npm-run-all prettier --parallel lint flow", "ci-check": "npm-run-all prettier:diff --parallel lint flow", - "dev": "gatsby develop -H 0.0.0.0", + "crowdin:download": "node ./crowdin/download", + "crowdin:update-languages": "node ./crowdin/update-languages", + "debug": "node --inspect-brk ./node_modules/.bin/gatsby develop -H 0.0.0.0", + "dev": "yarn start", "flow": "flow", "format:source": "prettier --config .prettierrc --write \"{gatsby-*.js,{flow-typed,plugins,src}/**/*.js}\"", "format:examples": "prettier --config .prettierrc.examples --write \"examples/**/*.js\"", "lint": "eslint .", - "netlify": "yarn install && yarn build", + "netlify": "yarn install && yarn crowdin:download && yarn build", "nit:source": "prettier --config .prettierrc --list-different \"{gatsby-*.js,{flow-typed,plugins,src}/**/*.js}\"", "nit:examples": "prettier --config .prettierrc.examples --list-different \"examples/**/*.js\"", + "prebuild": "yarn crowdin:update-languages", + "prestart": "yarn crowdin:update-languages", "prettier": "yarn format:source && yarn format:examples", "prettier:diff": "yarn nit:source && yarn nit:examples", - "reset": "rimraf ./.cache" + "reset": "yarn reset:cache && yarn reset:translations", + "reset:cache": "rimraf ./.cache", + "reset:translations": "rimraf ./crowdin/__translations && find crowdin/translations -type l -not -name '*en-US' -delete", + "start": "gatsby develop -H 0.0.0.0" }, "devDependencies": { "eslint-config-prettier": "^2.6.0", diff --git a/plugins/gatsby-plugin-crowdin/Link.js b/plugins/gatsby-plugin-crowdin/Link.js new file mode 100644 index 00000000000..caaaa41c2f5 --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/Link.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * @emails react-core + */ + +'use strict'; + +import Link from 'gatsby-link'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {getLanguageCodeFromPath} from './utils'; + +// TODO THis is a hack :( Pass this down via context or some other way? +const DEFAULT_LANGUAGE = 'en-US'; + +const DecoratedLink = ({isLocalized, location, to, ...rest}, ...other) => { + if (isLocalized !== false && to.startsWith('/')) { + const languageCode = + getLanguageCodeFromPath(location.pathname.substr(1)) || DEFAULT_LANGUAGE; + + to = `/${languageCode}${to}`; + } + + return React.createElement(Link, { + to, + ...rest, + }); +}; + +DecoratedLink.propTypes = { + isLocalized: PropTypes.bool, + location: PropTypes.object.isRequired, + to: PropTypes.string.isRequired, +}; + +export default DecoratedLink; diff --git a/plugins/gatsby-plugin-crowdin/gatsby-node.js b/plugins/gatsby-plugin-crowdin/gatsby-node.js new file mode 100644 index 00000000000..886c352e487 --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/gatsby-node.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.onCreateNode = require('./onCreateNode'); +exports.onCreatePage = require('./onCreatePage'); diff --git a/plugins/gatsby-plugin-crowdin/index.js b/plugins/gatsby-plugin-crowdin/index.js new file mode 100644 index 00000000000..25a549999e1 --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/index.js @@ -0,0 +1,26 @@ +const visit = require('unist-util-visit'); +const {getLanguageCodeFromPath} = require('./utils'); + +// This file "localizes" static markdown links during build-time +// eg /path/to/file.html => /zh-CN/path/to/file.html +// This is so Gatbsy will prefetch the correct language content +module.exports = ({markdownAST, markdownNode, getNode}, pluginOptions) => { + const parentNode = getNode(markdownNode.parent); + const {relativePath} = parentNode; + + let languageCode = getLanguageCodeFromPath(relativePath); + + // Only convert links for pages that contain language codes. + // TODO Does this upport linking from a Markdown page to a JavaScript page? + // Or will it incorrectly try to localize the static page? + if (languageCode !== null) { + visit(markdownAST, `link`, node => { + // Only prepand language code before root URLs (eg /path/to/file.html) + // Ignore relative links (eg file.html) + // And links to other domains (eg www.google.com) + if (node.url.startsWith('/')) { + node.url = `/${languageCode}${node.url}`; + } + }); + } +}; diff --git a/plugins/gatsby-plugin-crowdin/onCreateNode.js b/plugins/gatsby-plugin-crowdin/onCreateNode.js new file mode 100644 index 00000000000..88125d94160 --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/onCreateNode.js @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * @emails react-core + */ + +'use strict'; + +const { + getLanguageCodeFromPath, + getLanguageFromLanguageAndRegion, +} = require('./utils'); + +/** Params +{ + node: { + id: "...", + children: [], + parent: "...", + internal: { + content: "...", + contentDigest: "0351d452c1fabfe0eaec3faa9a60cde3", + type: "MarkdownRemark", + owner: "gatsby-transformer-remark" + }, + frontmatter: { + title: "...", + order: 1, + parent: "/path/to/parent/file/file.md" + }, + fileAbsolutePath: "/path/to/file.md" + } +} + */ +module.exports = exports.onCreateNode = ({ + node, + boundActionCreators, + getNode, +}) => { + const {createNodeField} = boundActionCreators; + + switch (node.internal.type) { + case 'MarkdownRemark': + // Parent node is owned by the 'gatsby-source-filesystem' plug-in + const {relativePath, sourceInstanceName} = getNode(node.parent); + + // We only need to attribute language metadata for "translated" sources + if (sourceInstanceName === 'translated') { + const languageCode = getLanguageCodeFromPath(relativePath); + const language = getLanguageFromLanguageAndRegion(languageCode); + + createNodeField({ + node, + name: 'language', + value: language, + }); + + createNodeField({ + node, + name: 'languageCode', + value: languageCode, + }); + } + return; + } +}; diff --git a/plugins/gatsby-plugin-crowdin/onCreatePage.js b/plugins/gatsby-plugin-crowdin/onCreatePage.js new file mode 100644 index 00000000000..18e219cb91b --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/onCreatePage.js @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * @emails react-core + */ + +'use strict'; + +/** Params +{ + page: { + layout: "index", + jsonName: "dev-404-page.json", + internalComponentName: "ComponentDev404Page", + path: "/dev-404-page/", + matchPath: undefined, + component: + "/Users/bvaughn/Documents/git/reactjs.org/.cache/dev-404-page.js", + componentChunkName: "component---cache-dev-404-page-js", + context: {}, + updatedAt: 1510471947417, + pluginCreator___NODE: "Plugin dev-404-page", + pluginCreatorId: "Plugin dev-404-page", + componentPath: + "/Users/bvaughn/Documents/git/reactjs.org/.cache/dev-404-page.js" + }, + traceId: "initial-createPages", + pathPrefix: "", + boundActionCreators: { + deletePage: [Function], + createPage: [Function], + deleteLayout: [Function], + createLayout: [Function], + deleteNode: [Function], + deleteNodes: [Function], + createNode: [Function], + touchNode: [Function], + createNodeField: [Function], + createParentChildLink: [Function], + createPageDependency: [Function], + deleteComponentsDependencies: [Function], + replaceComponentQuery: [Function], + createJob: [Function], + setJob: [Function], + endJob: [Function], + setPluginStatus: [Function], + createRedirect: [Function] + }, + loadNodeContent: [Function], + store: { + dispatch: [(Function: dispatch)], + subscribe: [(Function: subscribe)], + getState: [(Function: getState)], + replaceReducer: [(Function: replaceReducer)], + [Symbol(observable)]: [(Function: observable)] + }, + getNodes: [Function], + getNode: [(Function: getNode)], + hasNodeChanged: [Function], + reporter: undefined, + getNodeAndSavePathDependency: [Function], + cache: { + initCache: [Function], + get: [Function], + set: [Function] + } +} +*/ + +// TODO THis is a hack :( Read this from pluginOptions? +const DEFAULT_LANGUAGE_CODE = 'en-US'; + +// Gatsby has 2 types of pages: +// (1) Pages generated from Markdown content (src/templates) +// (2) Pages defined in JavaScript (src/pages) +// This plug-in makes no attempt to localize pages, only markdown content. +// One way to differnetiate between the 2 is to check for 'context.languageCode' +// This field gets inserted during markdown template creation. +module.exports = ({page, boundActionCreators}, pluginOptions) => { + const {createRedirect} = boundActionCreators; + + const { + context: { + language, // eg zn + languageCode, // eg zn-CH + }, + path, // eg /zn-CH/path/to/template.html, /path/to/page.html + } = page; + + // If we're creating a localized page, + // Create a non-localized redriect for the default language. + // TODO Maybe make this behavior configurable as well via pluginOptions + if (languageCode === DEFAULT_LANGUAGE_CODE) { + const nonLocalizedPath = path.substr( + path.indexOf(languageCode) + languageCode.length, + ); + + createRedirect({ + fromPath: nonLocalizedPath, + toPath: `/docs-language-redirect/?${nonLocalizedPath}`, + redirectInBrowser: true, + Language: language, + }); + } +}; diff --git a/plugins/gatsby-plugin-crowdin/package.json b/plugins/gatsby-plugin-crowdin/package.json new file mode 100644 index 00000000000..83fe1676a4b --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/package.json @@ -0,0 +1,8 @@ +{ + "name": "gatsby-plugin-crowdin", + "version": "0.0.1", + "dependencies": { + "gatsby-link": "^1.6.9", + "prop-types": "^15.6.0" + } +} \ No newline at end of file diff --git a/plugins/gatsby-plugin-crowdin/utils.js b/plugins/gatsby-plugin-crowdin/utils.js new file mode 100644 index 00000000000..c9bd33047ab --- /dev/null +++ b/plugins/gatsby-plugin-crowdin/utils.js @@ -0,0 +1,16 @@ +'use strict'; + +// Parses language code (e.g. en, zh-CH, fil-PH) from a path (eg en/path/to/file.js) +// Returns null if path doesn't contain a language code. +exports.getLanguageCodeFromPath = path => { + const match = path.match(/^([a-z]{2}|[a-z]{2,}-[A-Z]+)\//); + + return match ? match[1] : null; +}; + +// Parses a language (eg en, zn) from a langauge and region string (eg en-GB, zh-CH). +// If the specified param doesn't contain a region (eg en) then it is returned as-is. +exports.getLanguageFromLanguageAndRegion = languageAndRegion => + languageAndRegion.indexOf('-') + ? languageAndRegion.split('-')[0] + : languageAndRegion; diff --git a/src/components/ErrorDecoder/ErrorDecoder.js b/src/components/ErrorDecoder/ErrorDecoder.js index e25531bce59..7dac99a8331 100644 --- a/src/components/ErrorDecoder/ErrorDecoder.js +++ b/src/components/ErrorDecoder/ErrorDecoder.js @@ -39,9 +39,9 @@ function urlify(str: string): Node { // `?invariant=123&args[]=foo&args[]=bar` // or `// ?invariant=123&args[0]=foo&args[1]=bar` function parseQueryString( - search: string, + search: ?string, ): ?{|code: string, args: Array|} { - const rawQueryString = search.substring(1); + const rawQueryString = search ? search.substring(1) : null; if (!rawQueryString) { return null; } @@ -89,13 +89,15 @@ function ErrorResult(props: {|code: ?string, msg: string|}) { function ErrorDecoder(props: {| errorCodesString: string, - location: {search: string}, + location: {search: ?string}, |}) { let code = null; let msg = ''; - const errorCodes = JSON.parse(props.errorCodesString); - const parseResult = parseQueryString(props.location.search); + const {errorCodesString, location} = props; + + const errorCodes = JSON.parse(errorCodesString); + const parseResult = parseQueryString(location.search); if (parseResult != null) { code = parseResult.code; msg = replaceArgs(errorCodes[code], parseResult.args); diff --git a/src/components/LayoutFooter/Footer.js b/src/components/LayoutFooter/Footer.js index 0b9402c8ef0..c20f1d650db 100644 --- a/src/components/LayoutFooter/Footer.js +++ b/src/components/LayoutFooter/Footer.js @@ -134,6 +134,8 @@ const Footer = ({layoutHasSidebar = false}: {layoutHasSidebar: boolean}) => ( rel="noopener"> React Native + Versions + Translations
(
( ( href="/versions"> v{version} + + + + + + { let layoutHasSidebar = false; if ( location.pathname.match( - /^\/(docs|tutorial|community|blog|contributing|warnings)/, + /\/(docs|tutorial|community|blog|contributing|warnings)\//, ) ) { layoutHasSidebar = true; diff --git a/src/pages/blog/all.html.js b/src/pages/blog/all.html.js index e28c8c09dc0..24e8d114849 100644 --- a/src/pages/blog/all.html.js +++ b/src/pages/blog/all.html.js @@ -53,7 +53,7 @@ const AllBlogPosts = ({data}: Props) => ( width: '33.33%', }, }} - key={node.fields.slug}> + key={node.fields.id}>

( borderBottomColor: colors.black, }, }} - key={node.fields.slug} + key={node.fields.id} to={node.fields.slug}> {node.frontmatter.title} @@ -116,6 +116,7 @@ export const pageQuery = graphql` } fields { date(formatString: "MMMM DD, YYYY") + id slug } } diff --git a/src/pages/docs-language-redirect.js b/src/pages/docs-language-redirect.js new file mode 100644 index 00000000000..f3d4326d708 --- /dev/null +++ b/src/pages/docs-language-redirect.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * @emails react-core + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {Redirect} from 'react-router-dom'; +import {getSelectedLanguage} from 'utils/languageUtils'; + +const DocsRedirect = ({location}) => { + // Redirect the user to their most recent locale, or English as a fallback. + const language = getSelectedLanguage(); + + return ; +}; + +DocsRedirect.propTypes = { + location: PropTypes.object.isRequired, +}; + +export default DocsRedirect; diff --git a/src/pages/docs/error-decoder.html.js b/src/pages/docs/error-decoder.html.js index 4f97caf3008..d29dec68acf 100644 --- a/src/pages/docs/error-decoder.html.js +++ b/src/pages/docs/error-decoder.html.js @@ -100,8 +100,8 @@ const ErrorPage = ({data, location}: Props) => ( // eslint-disable-next-line no-undef export const pageQuery = graphql` - query ErrorPageMarkdown($slug: String!) { - markdownRemark(fields: {slug: {eq: $slug}}) { + query ErrorPageMarkdown { + markdownRemark(fields: {slug: {eq: "/docs/error-decoder.html"}}) { html fields { path diff --git a/src/pages/translations.js b/src/pages/translations.js new file mode 100644 index 00000000000..451734c61bf --- /dev/null +++ b/src/pages/translations.js @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * @emails react-core + * @flow + */ + +import Container from 'components/Container'; +import Header from 'components/Header'; +import TitleAndMetaTags from 'components/TitleAndMetaTags'; +import Link from 'gatsby-link'; +import React from 'react'; +import {sharedStyles} from 'theme'; +import {getTranslatedLanguages} from 'utils/languageUtils'; + +const Translations = () => ( + +
+ +
+ +); + +export default Translations; diff --git a/src/templates/blog.js b/src/templates/blog.js index 22255eb6921..477c621265a 100644 --- a/src/templates/blog.js +++ b/src/templates/blog.js @@ -18,6 +18,7 @@ const toSectionList = allMarkdownRemark => [ title: node.frontmatter.title, })) .concat({ + isLocalized: false, id: '/blog/all.html', title: 'All posts ...', }), @@ -43,8 +44,8 @@ Blog.propTypes = { // eslint-disable-next-line no-undef export const pageQuery = graphql` - query TemplateBlogMarkdown($slug: String!) { - markdownRemark(fields: {slug: {eq: $slug}}) { + query TemplateBlogMarkdown($id: String!) { + markdownRemark(fields: {id: {eq: $id}}) { html excerpt(pruneLength: 500) frontmatter { @@ -61,7 +62,7 @@ export const pageQuery = graphql` fields { date(formatString: "MMMM DD, YYYY") path - slug + id } } allMarkdownRemark( @@ -75,6 +76,7 @@ export const pageQuery = graphql` title } fields { + id slug } } diff --git a/src/templates/community.js b/src/templates/community.js index 15bab696b9f..9d181b08bad 100644 --- a/src/templates/community.js +++ b/src/templates/community.js @@ -26,8 +26,8 @@ Community.propTypes = { // eslint-disable-next-line no-undef export const pageQuery = graphql` - query TemplateCommunityMarkdown($slug: String!) { - markdownRemark(fields: {slug: {eq: $slug}}) { + query TemplateCommunityMarkdown($id: String!) { + markdownRemark(fields: {id: {eq: $id}}) { html frontmatter { title @@ -36,7 +36,7 @@ export const pageQuery = graphql` } fields { path - slug + id } } } diff --git a/src/templates/components/NavigationFooter/NavigationFooter.js b/src/templates/components/NavigationFooter/NavigationFooter.js index f5ce55aba32..96064bcebe9 100644 --- a/src/templates/components/NavigationFooter/NavigationFooter.js +++ b/src/templates/components/NavigationFooter/NavigationFooter.js @@ -11,7 +11,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {colors, fonts, media} from 'theme'; -const NavigationFooter = ({next, prev, location}) => { +const NavigationFooter = ({location, next, prev}) => { return (
( - -); +const Docs = ({data, location}) => { + // Store the user's most recent locale based on the current URL. + // We'll restore this language when they visit a new (unlocalized) URL. + const matches = location.pathname.substr(1).split('/'); + if (matches.length > 1) { + setSelectedLanguage(matches[0]); + } + + return ( + + ); +}; Docs.propTypes = { data: PropTypes.object.isRequired, }; +// allFile(filter: {internal: {mediaType: {eq: "text/markdown"}}, sourceInstanceName: {eq: "projects"}}) { +// allMarkdownRemark( +// filter: { fileAbsolutePath: {regex : "\/posts/"} }, +// sort: {fields: [frontmatter___date], order: DESC}, +// ) { +// https://github.com/gatsbyjs/gatsby/issues/1634 + // eslint-disable-next-line no-undef export const pageQuery = graphql` - query TemplateDocsMarkdown($slug: String!) { - markdownRemark(fields: {slug: {eq: $slug}}) { + query TemplateDocsMarkdown($id: String!) { + markdownRemark(fields: {id: {eq: $id}}) { html frontmatter { title @@ -36,7 +53,7 @@ export const pageQuery = graphql` } fields { path - slug + id } } } diff --git a/src/templates/tutorial.js b/src/templates/tutorial.js index bf65ca13d72..1886e611843 100644 --- a/src/templates/tutorial.js +++ b/src/templates/tutorial.js @@ -38,8 +38,8 @@ Tutorial.propTypes = { // eslint-disable-next-line no-undef export const pageQuery = graphql` - query TemplateTutorialMarkdown($slug: String!) { - markdownRemark(fields: {slug: {eq: $slug}}) { + query TemplateTutorialMarkdown($id: String!) { + markdownRemark(fields: {id: {eq: $id}}) { html frontmatter { title @@ -48,7 +48,7 @@ export const pageQuery = graphql` } fields { path - slug + id } } } diff --git a/src/types.js b/src/types.js index 782a5b6f6bd..4f70677941f 100644 --- a/src/types.js +++ b/src/types.js @@ -14,6 +14,7 @@ export type Node = { excerpt: string, fields: { date?: string, + id: string, path: string, redirect: string, slug: string, diff --git a/src/utils/createLink.js b/src/utils/createLink.js index 450acc454e0..e15d7689998 100644 --- a/src/utils/createLink.js +++ b/src/utils/createLink.js @@ -5,6 +5,7 @@ * @flow */ +import LocalizedLink from 'gatsby-plugin-crowdin/Link'; import Link from 'gatsby-link'; import React from 'react'; import ExternalLinkSvg from 'templates/components/ExternalLinkSvg'; @@ -15,17 +16,23 @@ import type {Node} from 'react'; type CreateLinkBaseProps = { isActive: boolean, + isLocalized: ?boolean, item: Object, + location: string, section: Object, }; const createLinkBlog = ({ isActive, item, + location, section, }: CreateLinkBaseProps): Node => { return ( - + {isActive && } {item.title} @@ -35,6 +42,7 @@ const createLinkBlog = ({ const createLinkCommunity = ({ isActive, item, + location, section, }: CreateLinkBaseProps): Node => { if (item.href) { @@ -54,23 +62,29 @@ const createLinkCommunity = ({ } return createLinkDocs({ isActive, + isLocalized: false, item, + location, section, }); }; const createLinkDocs = ({ isActive, + isLocalized, item, + location, section, }: CreateLinkBaseProps): Node => { return ( - {isActive && } {item.title} - + ); }; @@ -81,12 +95,14 @@ type CreateLinkTutorialProps = { const createLinkTutorial = ({ isActive, item, + location, onLinkClick, section, }: CreateLinkTutorialProps): Node => { return ( {isActive && } diff --git a/src/utils/languageUtils.js b/src/utils/languageUtils.js new file mode 100644 index 00000000000..fbfac00173b --- /dev/null +++ b/src/utils/languageUtils.js @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * @emails react-core + * @flow + */ + +// $FlowFixMe This is a valid path +import languagesArray from '../../crowdin/languages.json'; + +const DEFAULT_LANGUAGE = 'en-US'; + +const languagesMap = languagesArray.reduce((map: Object, language: string) => { + map[language] = true; + return map; +}, Object.create(null)); + +export function getTranslatedLanguages(): Array { + return languagesArray; +} + +export function getSelectedLanguage(): string { + let language = localStorage.getItem('selectedLanguage'); + if (languagesMap[language]) { + return ((language: any): string); + } else { + const {languages} = navigator; + for (let i = 0; i < languages.length; i++) { + language = languages[i]; + if (languagesMap[language]) { + return language; + } + } + } + return DEFAULT_LANGUAGE; +} + +export function setSelectedLanguage(language: string): void { + if (languagesMap[language]) { + localStorage.setItem('selectedLanguage', language); + } else if (process.env.NODE_ENV !== 'production') { + console.warn( + `Specified language "${language}" is not a valid translation.`, + ); + } +} diff --git a/yarn.lock b/yarn.lock index 59f3a6361bb..7eb33f8e3df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1491,6 +1491,13 @@ binary-extensions@^1.0.0: version "1.11.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" +"binary@>= 0.3.0 < 1": + version "0.3.0" + resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" + dependencies: + buffers "~0.1.1" + chainsaw "~0.1.0" + bl@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.1.tgz#cac328f7bee45730d404b692203fcb590e172d5e" @@ -1511,6 +1518,10 @@ bluebird@3.5.1, bluebird@^3.0.5, bluebird@^3.3.4, bluebird@^3.5.0: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" +bluebird@^2.9.21: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + bmp-js@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.0.3.tgz#64113e9c7cf1202b376ed607bf30626ebe57b18a" @@ -1751,6 +1762,10 @@ buffer@^4.3.0, buffer@^4.9.0: ieee754 "^1.1.4" isarray "^1.0.0" +buffers@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" + builtin-modules@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -1872,6 +1887,12 @@ center-align@^0.1.1: align-text "^0.1.3" lazy-cache "^1.0.3" +chainsaw@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" + dependencies: + traverse ">=0.3.0 <0.4" + chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -2413,6 +2434,17 @@ cross-spawn@5.1.0, cross-spawn@^5.0.1, cross-spawn@^5.1.0: shebang-command "^1.2.0" which "^1.2.9" +crowdin-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/crowdin-node/-/crowdin-node-1.0.0.tgz#17c734026607e24e6eb26eee36df4d7527b4a21c" + dependencies: + bluebird "^2.9.21" + graceful-fs "^3.0.6" + js-yaml "^3.2.7" + lodash "^3.6.0" + request "^2.54.0" + unzip "^0.1.11" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -3967,6 +3999,15 @@ fstream-ignore@^1.0.5: inherits "2" minimatch "^3.0.0" +"fstream@>= 0.1.30 < 1": + version "0.1.31" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-0.1.31.tgz#7337f058fbbbbefa8c9f561a28cab0849202c988" + dependencies: + graceful-fs "~3.0.2" + inherits "~2.0.0" + mkdirp "0.5" + rimraf "2" + fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2: version "1.0.11" resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171" @@ -4673,6 +4714,12 @@ got@^7.1.0: url-parse-lax "^1.0.0" url-to-options "^1.0.1" +graceful-fs@^3.0.6, graceful-fs@~3.0.2: + version "3.0.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-3.0.11.tgz#7613c778a1afea62f25c630a086d7f3acbbdd818" + dependencies: + natives "^1.1.0" + graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.4, graceful-fs@^4.1.6: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -5753,7 +5800,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" -js-yaml@^3.10.0, js-yaml@^3.5.2, js-yaml@^3.8.1, js-yaml@^3.9.1: +js-yaml@^3.10.0, js-yaml@^3.2.7, js-yaml@^3.5.2, js-yaml@^3.8.1, js-yaml@^3.9.1: version "3.10.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc" dependencies: @@ -6263,7 +6310,7 @@ lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" -lodash@3.10.1: +lodash@3.10.1, lodash@^3.6.0: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" @@ -6404,6 +6451,13 @@ markdown-table@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.1.tgz#4b3dd3a133d1518b8ef0dbc709bf2a1b4824bc8c" +"match-stream@>= 0.0.2 < 1": + version "0.0.2" + resolved "https://registry.yarnpkg.com/match-stream/-/match-stream-0.0.2.tgz#99eb050093b34dffade421b9ac0b410a9cfa17cf" + dependencies: + buffers "~0.1.1" + readable-stream "~1.0.0" + math-expression-evaluator@^1.2.14: version "1.2.17" resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" @@ -6706,7 +6760,7 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@0.5, mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" dependencies: @@ -6762,6 +6816,10 @@ nanomatch@^1.2.5: snapdragon "^0.8.1" to-regex "^3.0.1" +natives@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.0.tgz#e9ff841418a6b2ec7a495e939984f78f163e6e31" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -7192,6 +7250,10 @@ output-file-sync@^1.1.2: mkdirp "^0.5.1" object-assign "^4.1.0" +"over@>= 0.0.5 < 1": + version "0.0.5" + resolved "https://registry.yarnpkg.com/over/-/over-0.0.5.tgz#f29852e70fd7e25f360e013a8ec44c82aedb5708" + p-cancelable@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" @@ -8209,6 +8271,15 @@ public-encrypt@^4.0.0: parse-asn1 "^5.0.0" randombytes "^2.0.1" +"pullstream@>= 0.4.1 < 1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/pullstream/-/pullstream-0.4.1.tgz#d6fb3bf5aed697e831150eb1002c25a3f8ae1314" + dependencies: + over ">= 0.0.5 < 1" + readable-stream "~1.0.31" + setimmediate ">= 1.0.2 < 2" + slice-stream ">= 1.0.0 < 2" + pump@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" @@ -8494,7 +8565,7 @@ read@^1.0.7: dependencies: mute-stream "~0.0.4" -readable-stream@1.0, "readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.31: +readable-stream@1.0, "readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.0, readable-stream@~1.0.31: version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" dependencies: @@ -8914,7 +8985,7 @@ request@2.81.0: tunnel-agent "^0.6.0" uuid "^3.0.0" -request@^2.58.0, request@^2.65.0, request@^2.67.0, request@^2.74.0: +request@^2.54.0, request@^2.58.0, request@^2.65.0, request@^2.67.0, request@^2.74.0: version "2.83.0" resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356" dependencies: @@ -9320,7 +9391,7 @@ set-value@^2.0.0: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +"setimmediate@>= 1.0.1 < 2", "setimmediate@>= 1.0.2 < 2", setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -9425,6 +9496,12 @@ slice-ansi@1.0.0: dependencies: is-fullwidth-code-point "^2.0.0" +"slice-stream@>= 1.0.0 < 2": + version "1.0.0" + resolved "https://registry.yarnpkg.com/slice-stream/-/slice-stream-1.0.0.tgz#5b33bd66f013b1a7f86460b03d463dec39ad3ea0" + dependencies: + readable-stream "~1.0.31" + slugify@^1.2.1: version "1.2.7" resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.2.7.tgz#49998fa5f26e001ca366298937ad25fb6e9742cf" @@ -10234,6 +10311,10 @@ traceur@0.0.105: semver "^4.3.3" source-map-support "~0.2.8" +"traverse@>=0.3.0 <0.4": + version "0.3.9" + resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" + trim-lines@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-1.1.0.tgz#9926d03ede13ba18f7d42222631fb04c79ff26fe" @@ -10524,6 +10605,17 @@ unzip-response@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" +unzip@^0.1.11: + version "0.1.11" + resolved "https://registry.yarnpkg.com/unzip/-/unzip-0.1.11.tgz#89749c63b058d7d90d619f86b98aa1535d3b97f0" + dependencies: + binary ">= 0.3.0 < 1" + fstream ">= 0.1.30 < 1" + match-stream ">= 0.0.2 < 1" + pullstream ">= 0.4.1 < 1" + readable-stream "~1.0.31" + setimmediate ">= 1.0.1 < 2" + update-notifier@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.3.0.tgz#4e8827a6bb915140ab093559d7014e3ebb837451"