diff --git a/common/app/routes/challenges/components/map/Block.jsx b/common/app/routes/challenges/components/map/Block.jsx index 8b807a36ae..6f41a714dd 100644 --- a/common/app/routes/challenges/components/map/Block.jsx +++ b/common/app/routes/challenges/components/map/Block.jsx @@ -1,5 +1,5 @@ import React, { PropTypes } from 'react'; -import { Link } from 'react-router'; +import LangLink from '../../../../utils/Language-Link.jsx'; import { connect } from 'react-redux'; import FA from 'react-fontawesome'; import PureComponent from 'react-pure-render/component'; @@ -54,7 +54,7 @@ export class Block extends PureComponent { className={ challengeClassName } key={ title } > - + updateCurrentChallenge(challenge) } > @@ -66,7 +66,7 @@ export class Block extends PureComponent { '' } - +

); }); diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js index b785ca2a37..8fccade58c 100644 --- a/common/app/routes/challenges/redux/fetch-challenges-saga.js +++ b/common/app/routes/challenges/redux/fetch-challenges-saga.js @@ -24,6 +24,7 @@ export default function fetchChallengesSaga(action$, getState, { services }) { )) .flatMap(({ type, payload: { dashedName, block } = {} }) => { const state = getState(); + const lang = state.app.languageTag; if (type === replaceChallenge) { const { challenge: newChallenge } = challengeSelector({ ...state, @@ -38,8 +39,10 @@ export default function fetchChallengesSaga(action$, getState, { services }) { return Observable.just(null); } const options = { service: 'map' }; + options.params = { lang }; if (type === fetchChallenge) { - options.params = { dashedName, block }; + options.params.dashedName = dashedName; + options.params.block = block; } return services.readService$(options) .flatMap(({ entities, result, redirect } = {}) => { diff --git a/common/app/routes/index.js b/common/app/routes/index.js index b2ea7c0155..67e6e2bac0 100644 --- a/common/app/routes/index.js +++ b/common/app/routes/index.js @@ -2,7 +2,7 @@ import { modernChallenges, map, challenges } from './challenges'; import NotFound from '../components/NotFound/index.jsx'; export default { - path: '/', + path: '/:lang', childRoutes: [ challenges, modernChallenges, diff --git a/common/app/utils/Language-Link.jsx b/common/app/utils/Language-Link.jsx new file mode 100644 index 0000000000..0d6cf0d445 --- /dev/null +++ b/common/app/utils/Language-Link.jsx @@ -0,0 +1,42 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +import supportedLanguages from '../../utils/supported-languages'; + +const toLowerCase = String.prototype.toLowerCase; +function addLang(url, lang) { + const maybeLang = toLowerCase.call(url.split('/')[1]); + if (supportedLanguages[maybeLang]) { + return url; + } + if (supportedLanguages[lang]) { + return `/${lang}${url}`; + } + return `/en${url}`; +} + +const mapStateToProps = state => ({ lang: state.app.lang }); + +export class LangLink extends React.Component { + static displayName = 'LangLink'; + static propTypes = { + to: PropTypes.string, + lang: PropTypes.string + }; + + render() { + const { + to, + lang, + ...props + } = this.props; + return ( + + ); + } +} + +export default connect(mapStateToProps)(LangLink); diff --git a/common/models/user.json b/common/models/user.json index 9342b5cdb5..84e4e9ad51 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -195,6 +195,11 @@ "theme": { "type": "string", "default": "default" + }, + "languageTag": { + "type": "string", + "description": "A IETF language tag", + "default": "en" } }, "validations": [], @@ -256,6 +261,13 @@ "principalId": "$owner", "permission": "ALLOW", "property": "updateTheme" + }, + { + "accessType": "EXECUTE", + "principalType": "ROLE", + "principalId": "$owner", + "permission": "ALLOW", + "property": "updateLanguage" } ], "methods": {} diff --git a/common/utils/supported-languages.js b/common/utils/supported-languages.js new file mode 100644 index 0000000000..536ad617f4 --- /dev/null +++ b/common/utils/supported-languages.js @@ -0,0 +1,4 @@ +export default { + en: 'English', + es: 'Spanish' +}; diff --git a/server/boot/about.js b/server/boot/about.js index c58153577d..2665dffaef 100644 --- a/server/boot/about.js +++ b/server/boot/about.js @@ -83,5 +83,5 @@ export default function about(app) { } router.get('/about', showAbout); - app.use(router); + app.use('/:lang', router); } diff --git a/server/boot/commit.js b/server/boot/commit.js index 8f900fb59a..85e414533e 100644 --- a/server/boot/commit.js +++ b/server/boot/commit.js @@ -50,6 +50,7 @@ function findNonprofit(name) { export default function commit(app) { const router = app.loopback.Router(); + const api = app.loopback.Router(); const { Pledge } = app.models; router.get( @@ -68,19 +69,20 @@ export default function commit(app) { renderDirectory ); - router.post( + api.post( '/commit/stop-commitment', sendNonUserToCommit, stopCommit ); - router.post( + api.post( '/commit/complete-goal', sendNonUserToCommit, completeCommitment ); - app.use(router); + app.use(api); + app.use(':/lang', router); function commitToNonprofit(req, res, next) { const { user } = req; diff --git a/server/boot/home.js b/server/boot/home.js index a4e01ece1e..f788e8e17c 100644 --- a/server/boot/home.js +++ b/server/boot/home.js @@ -1,4 +1,5 @@ import { defaultProfileImage } from '../../common/utils/constantStrings.json'; +import supportedLanguages from '../../common/utils/supported-languages'; const message = 'Learn to Code and Help Nonprofits'; @@ -6,24 +7,37 @@ const message = module.exports = function(app) { var router = app.loopback.Router(); router.get('/', addDefaultImage, index); - + app.use( + '/:lang', + (req, res, next) => { + // add url language to request for all routers + req._urlLang = req.params.lang; + next(); + }, + router + ); app.use(router); function addDefaultImage(req, res, next) { if (!req.user || req.user.picture) { return next(); } - req.user.picture = defaultProfileImage; - return req.user.save(function(err) { - if (err) { return next(err); } - return next(); - }); + return req.user.update$({ picture: defaultProfileImage }) + .subscribe( + () => next(), + next + ); } - function index(req, res) { + function index(req, res, next) { + if (!supportedLanguages[req._urlLang]) { + return next(); + } + if (req.user) { return res.redirect('/challenges/current-challenge'); } + return res.render('home', { title: message }); } }; diff --git a/server/boot/randomAPIs.js b/server/boot/randomAPIs.js index 57ea58a5f4..1423ca9d5c 100644 --- a/server/boot/randomAPIs.js +++ b/server/boot/randomAPIs.js @@ -6,24 +6,32 @@ import secrets from '../../config/secrets'; module.exports = function(app) { const router = app.loopback.Router(); const User = app.models.User; - router.get('/api/github', githubCalls); - router.get('/chat', chat); - router.get('/coding-bootcamp-cost-calculator', bootcampCalculator); - router.get('/twitch', twitch); - router.get('/pmi-acp-agile-project-managers', agileProjectManagers); - router.get('/pmi-acp-agile-project-managers-form', agileProjectManagersForm); + const noLangRouter = app.loopback.Router(); + noLangRouter.get('/api/github', githubCalls); + noLangRouter.get('/chat', chat); + noLangRouter.get('/twitch', twitch); + noLangRouter.get('/unsubscribe/:email', unsubscribeMonthly); + noLangRouter.get( + '/unsubscribe-notifications/:email', + unsubscribeNotifications + ); + noLangRouter.get('/unsubscribe-quincy/:email', unsubscribeQuincy); + noLangRouter.get('/submit-cat-photo', submitCatPhoto); + noLangRouter.get( + '/the-fastest-web-page-on-the-internet', + theFastestWebPageOnTheInternet + ); + noLangRouter.get('/shop/cancel-stickers', cancelStickers); + noLangRouter.get('/shop/confirm-stickers', confirmStickers); + + router.get('/unsubscribed', unsubscribed); router.get('/nonprofits', nonprofits); router.get('/nonprofits-form', nonprofitsForm); - router.get('/unsubscribe/:email', unsubscribeMonthly); - router.get('/unsubscribe-notifications/:email', unsubscribeNotifications); - router.get('/unsubscribe-quincy/:email', unsubscribeQuincy); - router.get('/unsubscribed', unsubscribed); - router.get('/get-started', getStarted); - router.get('/submit-cat-photo', submitCatPhoto); + router.get('/pmi-acp-agile-project-managers', agileProjectManagers); + router.get('/pmi-acp-agile-project-managers-form', agileProjectManagersForm); + router.get('/coding-bootcamp-cost-calculator', bootcampCalculator); router.get('/stories', showTestimonials); router.get('/shop', showShop); - router.get('/shop/cancel-stickers', cancelStickers); - router.get('/shop/confirm-stickers', confirmStickers); router.get('/all-stories', showAllTestimonials); router.get('/terms', terms); router.get('/privacy', privacy); @@ -34,12 +42,9 @@ module.exports = function(app) { ); router.get('/code-of-conduct', codeOfConduct); router.get('/academic-honesty', academicHonesty); - router.get( - '/the-fastest-web-page-on-the-internet', - theFastestWebPageOnTheInternet - ); - app.use(router); + app.use(noLangRouter); + app.use('/:lang', router); function chat(req, res) { res.redirect('https://gitter.im/FreeCodeCamp/FreeCodeCamp'); @@ -242,12 +247,6 @@ module.exports = function(app) { }); } - function getStarted(req, res) { - res.render('resources/get-started', { - title: 'How to get started with Free Code Camp' - }); - } - function githubCalls(req, res, next) { var githubHeaders = { headers: { diff --git a/server/boot/a-react.js b/server/boot/react.js similarity index 88% rename from server/boot/a-react.js rename to server/boot/react.js index a627c10630..41de457b72 100644 --- a/server/boot/a-react.js +++ b/server/boot/react.js @@ -12,8 +12,6 @@ const log = debug('fcc:react-server'); // add routes here as they slowly get reactified // remove their individual controllers const routes = [ - '/videos', - '/videos/*', '/challenges', '/challenges/*', '/map' @@ -24,6 +22,12 @@ const devRoutes = []; export default function reactSubRouter(app) { var router = app.loopback.Router(); + router.get('/videos', (req, res) => res.redirect('/map')); + router.get( + '/videos/:dashedName', + (req, res) => res.redirect(`/challenges/${req.params.dashedName}`) + ); + // These routes are in production routes.forEach((route) => { router.get(route, serveReactApp); @@ -35,13 +39,15 @@ export default function reactSubRouter(app) { }); } - app.use(router); + app.use('/:lang', router); function serveReactApp(req, res, next) { + const { lang } = req; const serviceOptions = { req }; createApp({ serviceOptions, - location: req.path + location: req.originalUrl, + initialState: { app: { languageTag: lang } } }) // if react-router does not find a route send down the chain .filter(({ redirect, props }) => { diff --git a/server/boot/user.js b/server/boot/user.js index ad08bee719..18c13119a1 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -3,6 +3,7 @@ import moment from 'moment-timezone'; import { Observable } from 'rx'; import debugFactory from 'debug'; +import supportedLanguages from '../../common/utils/supported-languages'; import { frontEndChallengeId, dataVisChallengeId, @@ -123,8 +124,9 @@ function buildDisplayChallenges(challengeMap = {}, timezone) { } module.exports = function(app) { - var router = app.loopback.Router(); - var User = app.models.User; + const router = app.loopback.Router(); + const api = app.loopback.Router(); + const User = app.models.User; function findUserByUsername$(username, fields) { return observeQuery( User, @@ -145,39 +147,39 @@ module.exports = function(app) { router.get('/signin', getSignin); router.get('/signout', signout); router.get('/forgot', getForgot); - router.post('/forgot', postForgot); + api.post('/forgot', postForgot); router.get('/reset-password', getReset); - router.post('/reset-password', postReset); + api.post('/reset-password', postReset); router.get('/email-signup', getEmailSignup); router.get('/email-signin', getEmailSignin); router.get('/deprecated-signin', getDepSignin); router.get('/update-email', getUpdateEmail); - router.get( + api.get( '/toggle-lockdown-mode', sendNonUserToMap, toggleLockdownMode ); - router.get( + api.get( '/toggle-announcement-email-mode', sendNonUserToMap, toggleReceivesAnnouncementEmails ); - router.get( + api.get( '/toggle-notification-email-mode', sendNonUserToMap, toggleReceivesNotificationEmails ); - router.get( + api.get( '/toggle-quincy-email-mode', sendNonUserToMap, toggleReceivesQuincyEmails ); - router.post( + api.post( '/account/delete', ifNoUser401, postDeleteAccount ); - router.get( + api.get( '/account', sendNonUserToMap, getAccount @@ -188,32 +190,31 @@ module.exports = function(app) { flashIfNotVerified, getSettings ); - // router.get('/vote1', vote1); - // router.get('/vote2', vote2); // Ensure these are the last routes! - router.get( + api.get( '/:username/front-end-certification', showCert.bind(null, certTypes.frontEnd) ); - router.get( + api.get( '/:username/data-visualization-certification', showCert.bind(null, certTypes.dataVis) ); - router.get( + api.get( '/:username/back-end-certification', showCert.bind(null, certTypes.backEnd) ); - router.get( + api.get( '/:username/full-stack-certification', (req, res) => res.redirect(req.url.replace('full-stack', 'back-end')) ); router.get('/:username', returnUser); + app.use('/:lang', router); app.use(router); function getSignin(req, res) { @@ -280,7 +281,7 @@ module.exports = function(app) { function returnUser(req, res, next) { const username = req.params.username.toLowerCase(); - const { user, path } = req; + const { user } = req; // timezone of signed-in account // to show all date related components @@ -298,10 +299,7 @@ module.exports = function(app) { return User.findOne$(query) .filter(userPortfolio => { if (!userPortfolio) { - req.flash('errors', { - msg: `We couldn't find a page for ${ path }` - }); - res.redirect('/'); + next(); } return !!userPortfolio; }) @@ -354,7 +352,8 @@ module.exports = function(app) { calender, github: userPortfolio.githubURL, moment, - encodeFcc + encodeFcc, + supportedLanguages })); }) .doOnNext(data => { diff --git a/server/boot/z-lang-redirect.js b/server/boot/z-lang-redirect.js new file mode 100644 index 0000000000..b4ba8ded74 --- /dev/null +++ b/server/boot/z-lang-redirect.js @@ -0,0 +1,27 @@ +import supportedLanguages from '../../common/utils/supported-languages'; +import passThroughs from '../utils/lang-passthrough-urls'; +// import debug from 'debug'; + +// const log = debug('fcc:controller:lang-redirect'); +const toLowerCase = String.prototype.toLowerCase; + +export default function redirectLang(app) { + app.all('*', function(req, res, next) { + const { url, path } = req; + const langCode = toLowerCase.call(url.split('/')[1]); + + if (passThroughs[langCode]) { + return next(); + } + + if (supportedLanguages[langCode]) { + req.flash('errors', { + msg: `404: We couldn't find path ${ path }` + }); + return res.redirect('/map'); + } + + // language aware redirect + return res.redirect(url); + }); +} diff --git a/server/middleware.json b/server/middleware.json index 5732969e4e..08b468111d 100644 --- a/server/middleware.json +++ b/server/middleware.json @@ -50,6 +50,7 @@ "./middlewares/global-locals": {}, "./middlewares/revision-helpers": {}, "./middlewares/migrate-completed-challenges": {}, + "./middlewares/add-lang": {}, "./middlewares/flash-cheaters": {} }, "files": {}, diff --git a/server/middlewares/add-lang.js b/server/middlewares/add-lang.js new file mode 100644 index 0000000000..e59f58078d --- /dev/null +++ b/server/middlewares/add-lang.js @@ -0,0 +1,66 @@ +import supportedLanguages from '../../common/utils/supported-languages'; +import passthroughs from '../utils/lang-passthrough-urls'; +import debug from 'debug'; + +const log = debug('fcc:middlewares:lang'); +const langTagRegex = /^[a-z]{2}(?:-[a-zA-Z]{2,3})?$/; +const toLowerCase = String.prototype.toLowerCase; + +// redirect(statusOrUrl: String|Number, url?: String) => Void +function langRedirect(...args) { + const url = args.length === 2 ? args[1] : args[0]; + const { lang } = this.req; + const maybeLang = toLowerCase.call(url.split('/')[1]); + + if ( + passthroughs[maybeLang] || + supportedLanguages[maybeLang] + ) { + return this._oldRedirect(...arguments); + } + + // if language present add to url + if (lang) { + return this._oldRedirect(`/${lang}${url}`); + } + + // default to english + return this._oldRedirect(`/en${url}`); +} + +// prefer url lang over user lang +// if url lang is not supported move to user lang +// if user lang is not supported default to english +export default function addLang() { + return function(req, res, next) { + const { url, user = {} } = req; + const maybeLang = url.split('/')[1]; + const restUrl = url.split('/').slice(2).join('/'); + const userLang = user.languageTag; + let finalLang; + if (supportedLanguages[maybeLang]) { + finalLang = maybeLang; + } else if (supportedLanguages[userLang]) { + finalLang = userLang; + } else { + finalLang = 'en'; + } + // found url lang tag that is not yet supported + // redirect to fix url with supported lang tag + if (langTagRegex.test(maybeLang) && !supportedLanguages[maybeLang]) { + log(`unsupported lang tag ${maybeLang}`); + return res.redirect(`/${finalLang}/${restUrl}`); + } + res.locals.supportedLanguages = supportedLanguages; + + if (supportedLanguages[finalLang]) { + req.lang = finalLang; + res.locals.lang = finalLang; + } + + res._oldRedirect = res.redirect; + res.redirect = langRedirect; + + return next(); + }; +} diff --git a/server/services/map.js b/server/services/map.js index 5caba3cd89..5f56975983 100644 --- a/server/services/map.js +++ b/server/services/map.js @@ -1,12 +1,13 @@ import { Observable } from 'rx'; import { Schema, valuesOf, arrayOf, normalize } from 'normalizr'; -import { nameify, dasherize, unDasherize } from '../utils'; import debug from 'debug'; +import { nameify, dasherize, unDasherize } from '../utils'; +import supportedLanguages from '../../common/utils/supported-languages'; const isDev = process.env.NODE_ENV !== 'production'; const isBeta = !!process.env.BETA; const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; -const log = debug('fcc:challenges'); +const log = debug('fcc:services:challenges'); const challenge = new Schema('challenge', { idAttribute: 'dashedName' }); const block = new Schema('block', { idAttribute: 'dashedName' }); const superBlock = new Schema('superBlock', { idAttribute: 'dashedName' }); @@ -99,6 +100,34 @@ function cachedMap(Block) { .shareReplay(); } +function mapChallengeToLang({ translations = {}, ...challenge }, lang) { + if (!supportedLanguages[lang]) { + lang = 'en'; + } + if (lang !== 'en') { + challenge.title = + translations[lang] && translations[lang].title || + challenge.title; + + challenge.description = + translations[lang] && translations[lang].description || + challenge.description; + } + return challenge; +} + +function getMapForLang(lang) { + return ({ entities: { challenge: challengeMap, ...entities }, result }) => { + entities.challenge = Object.keys(challengeMap) + .reduce((translatedChallengeMap, key) => { + translatedChallengeMap[key] = + mapChallengeToLang(challengeMap[key], lang); + return translatedChallengeMap; + }, {}); + return { result, entities }; + }; +} + function shouldNotFilterComingSoon({ isComingSoon, isBeta: challengeIsBeta }) { return isDev || !isComingSoon || @@ -123,7 +152,8 @@ function getFirstChallenge(challengeMap$) { function getChallengeAndBlock( challengeDashedName, blockDashedName, - challengeMap$ + challengeMap$, + lang ) { return challengeMap$ .flatMap(({ entities }) => { @@ -134,7 +164,11 @@ function getChallengeAndBlock( !challenge || !shouldNotFilterComingSoon(challenge) ) { - return getChallengeByDashedName(challengeDashedName, challengeMap$); + return getChallengeByDashedName( + challengeDashedName, + challengeMap$, + lang + ); } return Observable.just({ redirect: block.dashedName !== blockDashedName ? @@ -142,7 +176,7 @@ function getChallengeAndBlock( false, entities: { challenge: { - [challenge.dashedName]: challenge + [challenge.dashedName]: mapChallengeToLang(challenge, lang) } }, result: { @@ -153,7 +187,7 @@ function getChallengeAndBlock( }); } -function getChallengeByDashedName(dashedName, challengeMap$) { +function getChallengeByDashedName(dashedName, challengeMap$, lang) { const challengeName = unDasherize(dashedName) .replace(challengesRegex, ''); const testChallengeName = new RegExp(challengeName, 'i'); @@ -179,7 +213,11 @@ function getChallengeByDashedName(dashedName, challengeMap$) { .map(challenge => ({ redirect: `/challenges/${challenge.block}/${challenge.dashedName}`, - entities: { challenge: { [challenge.dashedName]: challenge } }, + entities: { + challenge: { + [challenge.dashedName]: mapChallengeToLang(challenge, lang) + } + }, result: { challenge: challenge.dashedName, block: challenge.block @@ -192,16 +230,19 @@ export default function mapService(app) { const challengeMap$ = cachedMap(Block); return { name: 'map', - read: (req, resource, { block, dashedName } = {}, config, cb) => { + read: (req, resource, { lang, block, dashedName } = {}, config, cb) => { + log(`${lang} language requested`); if (block && dashedName) { - return getChallengeAndBlock(dashedName, block, challengeMap$) + return getChallengeAndBlock(dashedName, block, challengeMap$, lang) .subscribe(challenge => cb(null, challenge), cb); } if (dashedName) { - return getChallengeByDashedName(dashedName, challengeMap$) + return getChallengeByDashedName(dashedName, challengeMap$, lang) .subscribe(challenge => cb(null, challenge), cb); } - return challengeMap$.subscribe(map => cb(null, map), cb); + return challengeMap$ + .map(getMapForLang(lang)) + .subscribe(map => cb(null, map), cb); } }; } diff --git a/server/utils/lang-passthrough-urls.js b/server/utils/lang-passthrough-urls.js new file mode 100644 index 0000000000..4799f3c457 --- /dev/null +++ b/server/utils/lang-passthrough-urls.js @@ -0,0 +1,4 @@ +export default [ + 'auth', + 'services' +].reduce((throughs, route) => (throughs[route] = true, throughs), {}); diff --git a/server/views/account/settings.jade b/server/views/account/settings.jade index 4733bab150..fee9250cae 100644 --- a/server/views/account/settings.jade +++ b/server/views/account/settings.jade @@ -103,7 +103,13 @@ block content a.btn.btn-lg.btn-block.btn-primary.btn-link-social(href='/update-email') i.fa.fa-envelope | Update my Email - + if supportedLanguages + .col-xs-12 + select#lang-select.form-control.btn.btn-lg.btn-block.btn-primary.btn-link-social(name='langTag') + option(disabled selected=languageTag ? false : true) Prefered Language + for languageDisplay, lang in supportedLanguages + option(value=lang selected=lang === languageTag ? 'selected' : false)= languageDisplay + option(disabled) More to come... .spacer h2.text-center Danger Zone .row