Feat: News in the client app (#34392)

This commit is contained in:
Stuart Taylor
2018-11-29 12:12:15 +00:00
committed by Valeriy
parent 28798dc008
commit d327a5c36b
87 changed files with 2334 additions and 1403 deletions

View File

@ -1,214 +1,259 @@
// import React from 'react';
// import { renderToString } from 'react-dom/server';
// // import { StaticRouter } from 'react-router-dom';
// import { has } from 'lodash';
import { has, pick, isEmpty } from 'lodash';
import debug from 'debug';
import { reportError } from '../middlewares/error-reporter';
// import NewsApp from '../../news/NewsApp';
const routerLog = debug('fcc:boot:news:router');
const apiLog = debug('fcc:boot:news:api');
const log = debug('fcc:boot:news');
export default function newsBoot(app) {
// const router = app.loopback.Router();
// const api = app.loopback.Router();
const api = app.loopback.Router();
// router.get('/n', (req, res) => res.redirect('/news'));
// router.get('/n/:shortId', createShortLinkHandler(app));
api.get('/n/:shortId', createShortLinkHandler(app));
// router.get('/news', serveNewsApp);
// router.get('/news/*', serveNewsApp);
api.post('/p', createPopularityHandler(app));
// api.post('/p', createPopularityHandler(app));
// app.use(api);
// app.use(router);
app.use('/internal', api);
}
// function serveNewsApp(req, res) {
// const context = {};
// const markup = renderToString(
// <StaticRouter basename='/news' context={context} location={req.url}>
// <NewsApp />
// </StaticRouter>
// );
// if (context.url) {
// routerLog('redirect found in `renderToString`');
// // 'client-side' routing hit on a redirect
// return res.redirect(context.url);
// }
// routerLog('news markup sending');
// return res.render('layout-news', { title: 'News | freeCodeCamp', markup });
// }
function createShortLinkHandler(app) {
const { Article } = app.models;
// function createShortLinkHandler(app) {
// const { Article } = app.models;
const referralHandler = createReferralHandler(app);
// const referralHandler = createRerralHandler(app);
return function shortLinkHandler(req, res, next) {
const { query, user } = req;
const { shortId } = req.params;
// return function shortLinkHandler(req, res, next) {
// const { query, user } = req;
// const { shortId } = req.params;
// We manually report the error here as it should not affect this request
referralHandler(query, shortId, !!user).catch(err => reportError(err));
// referralHandler(query, shortId, !!user);
if (!shortId) {
return res.sendStatus(400);
}
return Article.findOne(
{
where: {
or: [{ shortId }, { slugPart: shortId }]
}
},
(err, article) => {
if (err) {
next(err);
}
if (!article) {
return res.status(404).send('Could not find article by shortId');
}
const {
slugPart,
shortId,
author: { username }
} = article;
const slug = `/news/${username}/${slugPart}--${shortId}`;
const articleData = {
...pick(article, [
'author',
'renderableContent',
'firstPublishedDate',
'viewCount',
'title',
'featureImage',
'slugPart',
'shortId',
'meta'
]),
slug
};
return res.json(articleData);
}
);
};
}
// routerLog(req.origin);
// routerLog(query.refsource);
// if (!shortId) {
// return res.redirect('/news');
// }
// routerLog('shortId', shortId);
// return Article.findOne(
// {
// where: {
// or: [{ shortId }, { slugPart: shortId }]
// }
// },
// (err, article) => {
// if (err) {
// next(err);
// }
// if (!article) {
// return res.redirect('/news');
// }
// const {
// slugPart,
// shortId,
// author: { username }
// } = article;
// const slug = `/news/${username}/${slugPart}--${shortId}`;
// return res.redirect(slug);
// }
// );
// };
// }
function createPopularityHandler(app) {
const { Article, Popularity } = app.models;
// function createPopularityHandler(app) {
// const { Article, Popularity } = app.models;
function findArticleByShortId(shortId) {
return new Promise((resolve, reject) =>
Article.findOne({ where: { shortId } }, (err, article) => {
if (err) {
log('Error returned from Article.findOne(shortId)');
return reject(err);
}
log('article found');
return resolve(article);
})
);
}
// return function handlePopularityStats(req, res, next) {
// const { body, user } = req;
function findPopularityByShortId(shortId) {
return new Promise((resolve, reject) =>
Popularity.findOne(
{ where: { articleId: shortId } },
(err, popularity) => {
if (err) {
log('Error returned from Popularity.findOne(shortId)');
return reject(err);
}
log('popularity found');
return resolve(popularity);
}
)
);
}
// if (
// !has(body, 'event') ||
// !has(body, 'timestamp') ||
// !has(body, 'shortId')
// ) {
// console.warn('Popularity event recieved from client is malformed');
// console.log(JSON.stringify(body, null, 2));
// // sending 200 because the client shouldn't care for this
// return res.sendStatus(200);
// }
// res.sendStatus(200);
// const { shortId } = body;
// apiLog('shortId', shortId);
// const populartiyUpdate = {
// ...body,
// byAuthenticatedUser: !!user
// };
// Popularity.findOne({ where: { articleId: shortId } }, (err, popularity) => {
// if (err) {
// apiLog(err);
// return next(err);
// }
// if (popularity) {
// return popularity.updateAttribute(
// 'events',
// [populartiyUpdate, ...popularity.events],
// err => {
// if (err) {
// apiLog(err);
// return next(err);
// }
// return apiLog('poplarity updated');
// }
// );
// }
// return Popularity.create(
// {
// events: [populartiyUpdate],
// articleId: shortId
// },
// err => {
// if (err) {
// apiLog(err);
// return next(err);
// }
// return apiLog('poulartiy created');
// }
// );
// });
// return body.event === 'view'
// ? Article.findOne({ where: { shortId } }, (err, article) => {
// if (err) {
// apiLog(err);
// next(err);
// }
// return article.updateAttributes(
// { viewCount: article.viewCount + 1 },
// err => {
// if (err) {
// apiLog(err);
// return next(err);
// }
// return apiLog('article views updated');
// }
// );
// })
// : null;
// };
// }
function createPopularity(popularityUpdate, shortId) {
return new Promise((resolve, reject) =>
Popularity.create(
{
events: [popularityUpdate],
articleId: shortId
},
err => {
if (err) {
return reject(err);
}
log('poulartiy created');
return resolve();
}
)
);
}
// function createRerralHandler(app) {
// const { Popularity } = app.models;
function updatePopularity(popularity, popularityUpdate) {
return new Promise((resolve, reject) =>
popularity.updateAttribute(
'events',
[popularityUpdate, ...popularity.events],
err => {
if (err) {
log('Error returned from popularity.updateAttribute()');
return reject(err);
}
log('poplarity updated');
return resolve();
}
)
);
}
// return function referralHandler(query, shortId, byAuthenticatedUser) {
// if (!query.refsource) {
// return null;
// }
// const eventUpdate = {
// event: `referral - ${query.refsource}`,
// timestamp: new Date(Date.now()),
// byAuthenticatedUser
// };
// return Popularity.findOne(
// { where: { articleId: shortId } },
// (err, popularity) => {
// if (err) {
// console.error(
// 'Failed finding a `Popularity` in a referral handler',
// err
// );
// return null;
// }
function incrementArticleViews(article) {
return new Promise((resolve, reject) =>
article.updateAttributes({ $inc: { viewCount: 1 } }, err => {
if (err) {
log(err);
return reject(err);
}
log('article views updated');
return resolve();
})
);
}
// if (popularity) {
// return popularity.updateAttribute(
// 'events',
// [eventUpdate, ...popularity.events],
// err => {
// if (err) {
// console.error(
// 'Failed in updating the `events` attribute of a `popularity`',
// err
// );
// }
// }
// );
// }
// return Popularity.create(
// {
// events: [eventUpdate],
// articleId: shortId
// },
// err => {
// if (err) {
// return console.error('Failed creating a new `Popularity`', err);
// }
// return apiLog('poulartiy created');
// }
// );
// }
// );
// };
// }
return async function handlePopularityStats(req, res, next) {
const { body, user } = req;
if (
!has(body, 'event') ||
!has(body, 'timestamp') ||
!has(body, 'shortId')
) {
console.warn('Popularity event recieved from client is malformed');
console.log(JSON.stringify(body, null, 2));
// sending 200 because the client shouldn't care for this
return res.sendStatus(200);
}
const { shortId } = body;
log('shortId', shortId);
const articlePromise = findArticleByShortId(shortId);
const popularityPromise = findPopularityByShortId(shortId);
const [article, popularity] = await Promise.all([
articlePromise,
popularityPromise
]).catch(err => {
log('find catch');
return next(err);
});
if (!article || isEmpty(article)) {
log('No article found to handle the populartity update');
// sending 200 because the client shouldn't care for this
return res.sendStatus(200);
}
const populartiyUpdate = {
...body,
byAuthenticatedUser: !!user
};
const populartiyUpdateOrCreatePromise = isEmpty(popularity)
? createPopularity(populartiyUpdate, shortId)
: updatePopularity(popularity, populartiyUpdate);
const maybeUpdateArticlePromise =
body.event === 'view' ? incrementArticleViews(article) : null;
return Promise.all([
populartiyUpdateOrCreatePromise,
maybeUpdateArticlePromise
])
.then(() => res.sendStatus(200))
.catch(err => {
log('updates catch');
return next(err);
});
};
}
function createReferralHandler(app) {
const { Popularity } = app.models;
return function referralHandler(query, shortId, byAuthenticatedUser) {
if (!query.refsource) {
return Promise.resolve();
}
const eventUpdate = {
event: `referral - ${query.refsource}`,
timestamp: new Date(Date.now()),
byAuthenticatedUser
};
return new Promise((resolve, reject) =>
Popularity.findOne(
{ where: { articleId: shortId } },
(err, popularity) => {
if (err) {
console.error(
'Failed finding a `Popularity` in a referral handler',
err
);
return reject(err);
}
if (popularity) {
return popularity.updateAttribute(
'events',
[eventUpdate, ...popularity.events],
err => {
if (err) {
return reject(err);
}
log('populartiy updated');
return resolve();
}
);
}
return Popularity.create(
{
events: [eventUpdate],
articleId: shortId
},
err => {
if (err) {
return reject(err);
}
log('poulartiy created');
return resolve();
}
);
}
)
);
};
}

View File

@ -5,7 +5,8 @@ module.exports = {
connector: 'mongodb',
connectionTimeout: 10000,
url: secrets.db,
useNewUrlParser: true
useNewUrlParser: true,
allowExtendedOperators: true
},
mail: {
connector: 'mail',

View File

@ -8,47 +8,10 @@ import { unwrapHandledError } from '../utils/create-handled-error.js';
const isDev = process.env.NODE_ENV !== 'production';
// const toString = Object.prototype.toString;
// is full error or just trace
// _.toString(new Error('foo')) => "Error: foo
// Object.prototype.toString.call(new Error('foo')) => "[object Error]"
// const isInspect = val => !val.stack && _.toString(val) === toString.call(val);
// const stringifyErr = val => {
// if (val.stack) {
// return String(val.stack);
// }
// const str = String(val);
// return isInspect(val) ?
// inspect(val) :
// str;
// };
// const createStackHtml = _.flow(
// _.cond([
// [isInspect, err => [err]],
// // may be stack or just err.msg
// [_.stubTrue, _.flow(stringifyErr, _.split('\n'), _.tail) ]
// ]),
// _.map(_.escape),
// _.map(line => `<li>${line}</lin>`),
// _.join('')
// );
// const createErrorTitle = _.cond([
// [
// _.negate(isInspect),
// _.flow(stringifyErr, _.split('\n'), _.head, _.defaultTo('Error'))
// ],
// [_.stubTrue, _.constant('Error')]
// ]);
export default function prodErrorHandler() {
// error handling in production.
// disabling eslint due to express parity rules for error handlers
// eslint-disable-next-line no-unused-vars
return function(err, req, res, next) {
// eslint-disable-line
const handled = unwrapHandledError(err);
// respect handled error status
let status = handled.status || err.status || res.statusCode;

View File

@ -5,20 +5,30 @@ import {
unwrapHandledError
} from '../utils/create-handled-error.js';
const { ROLLBAR_APP_ID } = process.env;
import { rollbar } from '../../../config/secrets';
const rollbar = new Rollbar(ROLLBAR_APP_ID);
const { appId } = rollbar;
const reporter = new Rollbar(appId);
const log = debug('fcc:middlewares:error-reporter');
const errTemplate = ({message, ...restError}, req) => `
const errTemplate = (error, req) => {
const { message, stack } = error;
return `
Time: ${new Date(Date.now()).toISOString()}
Error: ${message}
Is authenticated user: ${!!req.user}
Route: ${JSON.stringify(req.route, null, 2)}
Stack: ${stack}
${JSON.stringify(restError, null, 2)}
// raw
${JSON.stringify(error, null, 2)}
`;
};
export function reportError(err) {
return reporter.error(err.message, err);
}
export default function errrorReporter() {
if (process.env.NODE_ENV !== 'production' && process.env.ERROR_REPORTER) {
@ -44,6 +54,7 @@ export default function errrorReporter() {
// logging the error provides us with more information,
// i.e isAuthenticatedUser, req.route
console.error(errTemplate(err, req));
return rollbar.error(err.message, err);
reportError(err);
return next(err);
};
}

View File

@ -6,9 +6,17 @@ import { homeLocation } from '../../../config/env';
import { wrapHandledError } from '../utils/create-handled-error';
// We need to tunnel through a proxy path set up within
// the gatsby app, at this time, that path is /internal
const whiteListRE = new RegExp([
'^/internal/n/',
'^/internal/p\??'
].join('|'));
export default () => function authorizeByJWT(req, res, next) {
const path = req.path.split('/')[1];
if (/^external$|^internal$/.test(path)) {
if (/^external$|^internal$/.test(path) && !whiteListRE.test(req.path)) {
const cookie = req.signedCookies && req.signedCookies['jwt_access_token'] ||
req.cookie && req.cookie['jwt_access_token'];
if (!cookie) {

View File

@ -32,7 +32,8 @@ module.exports = {
'/certification/*',
'/unsubscribed/*',
'/user/*',
'/settings/*'
'/settings/*',
'/n/*'
]
}
},
@ -45,6 +46,12 @@ module.exports = {
curriculumPath: localeChallengesRootDir
}
},
{
resolve: 'fcc-source-news',
options: {
maximumStaticRenderCount: 100
}
},
{
resolve: 'gatsby-source-filesystem',
options: {

View File

@ -8,8 +8,10 @@ const {
createChallengePages,
createBlockIntroPages,
createSuperBlockIntroPages,
createGuideArticlePages
createGuideArticlePages,
createNewsArticle
} = require('./utils/gatsby');
const { createArticleSlug } = require('./utils/news');
const createByIdentityMap = {
guideMarkdown: createGuideArticlePages,
@ -35,6 +37,15 @@ exports.onCreateNode = function onCreateNode({ node, actions, getNode }) {
createNodeField({ node, name: 'slug', value: slug });
}
}
if (node.internal.type === 'NewsArticleNode') {
const {
author: { username },
slugPart,
shortId
} = node;
const slug = createArticleSlug({ username, shortId, slugPart });
createNodeField({ node, name: 'slug', value: slug });
}
};
exports.createPages = function createPages({ graphql, actions }) {
@ -86,6 +97,19 @@ exports.createPages = function createPages({ graphql, actions }) {
}
}
}
allNewsArticleNode(
sort: { fields: firstPublishedDate, order: DESC }
) {
edges {
node {
id
shortId
fields {
slug
}
}
}
}
}
`).then(result => {
if (result.errors) {
@ -126,6 +150,11 @@ exports.createPages = function createPages({ graphql, actions }) {
return null;
});
// Create news article pages
result.data.allNewsArticleNode.edges.forEach(
createNewsArticle(createPage)
);
return null;
})
);
@ -161,7 +190,9 @@ exports.onCreateWebpackConfig = ({ stage, rules, plugins, actions }) => {
HOME_PATH: JSON.stringify(
process.env.HOME_PATH || 'http://localhost:3000'
),
STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY || '')
STRIPE_PUBLIC_KEY: JSON.stringify(process.env.STRIPE_PUBLIC_KEY || ''),
ROLLBAR_CLIENT_ID: JSON.stringify(process.env.ROLLBAR_CLIENT_ID || ''),
ENVIRONMENT: JSON.stringify(process.env.NODE_ENV || 'development')
}),
new RmServiceWorkerPlugin()
]

File diff suppressed because one or more lines are too long

101
client/package-lock.json generated
View File

@ -1205,9 +1205,10 @@
}
},
"@sinonjs/commons": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.0.2.tgz",
"integrity": "sha512-WR3dlgqJP4QNrLC4iXN/5/2WaLQQ0VijOOkmflqFGVJ6wLEpbSjo7c0ZeGIdtY8Crk7xBBp87sM6+Mkerz7alw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz",
"integrity": "sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA==",
"dev": true,
"requires": {
"type-detect": "4.0.8"
}
@ -1216,6 +1217,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.0.0.tgz",
"integrity": "sha512-vdjoYLDptCgvtJs57ULshak3iJe4NW3sJ3g36xVDGff5AE8P30S6A093EIEPjdi2noGhfuNOEkbxt3J3awFW1w==",
"dev": true,
"requires": {
"@sinonjs/samsam": "2.1.0"
},
@ -1224,6 +1226,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.0.tgz",
"integrity": "sha512-5x2kFgJYupaF1ns/RmharQ90lQkd2ELS8A9X0ymkAAdemYHGtI2KiUHG8nX2WU0T1qgnOU5YMqnBM2V7NUanNw==",
"dev": true,
"requires": {
"array-from": "^2.1.1"
}
@ -1231,12 +1234,10 @@
}
},
"@sinonjs/samsam": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.1.tgz",
"integrity": "sha512-7oX6PXMulvdN37h88dvlvRyu61GYZau40fL4wEZvPEHvrjpJc3lDv6xDM5n4Z0StufUVB5nDvVZUM+jZHdMOOQ==",
"requires": {
"array-from": "^2.1.1"
}
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.2.tgz",
"integrity": "sha512-ZwTHAlC9akprWDinwEPD4kOuwaYZlyMwVJIANsKNC3QVp0AHB04m7RnB4eqeWfgmxw8MGTzS9uMaw93Z3QcZbw==",
"dev": true
},
"@types/configstore": {
"version": "2.1.1",
@ -1781,7 +1782,8 @@
"array-from": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz",
"integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU="
"integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=",
"dev": true
},
"array-includes": {
"version": "3.0.3",
@ -4731,7 +4733,8 @@
"diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA=="
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
"dev": true
},
"diffie-hellman": {
"version": "5.0.3",
@ -10758,7 +10761,8 @@
"just-extend": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-3.0.0.tgz",
"integrity": "sha512-Fu3T6pKBuxjWT/p4DkqGHFRsysc8OauWr4ZRTY9dIx07Y9O0RkoR5jcv28aeD1vuAwhm3nLkDurwLXoALp4DpQ=="
"integrity": "sha512-Fu3T6pKBuxjWT/p4DkqGHFRsysc8OauWr4ZRTY9dIx07Y9O0RkoR5jcv28aeD1vuAwhm3nLkDurwLXoALp4DpQ==",
"dev": true
},
"kebab-case": {
"version": "1.0.0",
@ -11017,7 +11021,8 @@
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
"dev": true
},
"lodash.isequal": {
"version": "4.5.0",
@ -11143,7 +11148,8 @@
"lolex": {
"version": "2.7.5",
"resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz",
"integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q=="
"integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==",
"dev": true
},
"longest-streak": {
"version": "2.0.2",
@ -11779,9 +11785,10 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"nise": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/nise/-/nise-1.4.5.tgz",
"integrity": "sha512-OHRVvdxKgwZELf2DTgsJEIA4MOq8XWvpSUzoOXyxJ2mY0mMENWC66+70AShLR2z05B1dzrzWlUQJmJERlOUpZw==",
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/nise/-/nise-1.4.6.tgz",
"integrity": "sha512-1GedetLKzmqmgwabuMSqPsT7oumdR77SBpDfNNJhADRIeA3LN/2RVqR4fFqwvzhAqcTef6PPCzQwITE/YQ8S8A==",
"dev": true,
"requires": {
"@sinonjs/formatio": "3.0.0",
"just-extend": "^3.0.0",
@ -11793,12 +11800,14 @@
"isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=",
"dev": true
},
"path-to-regexp": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz",
"integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=",
"dev": true,
"requires": {
"isarray": "0.0.1"
}
@ -14775,6 +14784,23 @@
}
}
},
"react-youtube": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-7.8.0.tgz",
"integrity": "sha512-kQFR0XTpgGRtzJ54HKDaCtAGr34mgB/BVFeCAL0WUjpIKZBcWtFrKJhYkoKfvWK7aTzJuQ57xojTjG7V6JzILA==",
"requires": {
"fast-deep-equal": "^2.0.1",
"prop-types": "^15.5.3",
"youtube-player": "^5.5.1"
},
"dependencies": {
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
}
}
},
"read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@ -15923,21 +15949,27 @@
}
},
"sinon": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-6.3.4.tgz",
"integrity": "sha512-NIaR56Z1mefuRBXYrf4otqBxkWiKveX+fvqs3HzFq2b07HcgpkMgIwmQM/owNjNFAHkx0kJXW+Q0mDthiuslXw==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-6.3.5.tgz",
"integrity": "sha512-xgoZ2gKjyVRcF08RrIQc+srnSyY1JDJtxu3Nsz07j1ffjgXoY6uPLf/qja6nDBZgzYYEovVkFryw2+KiZz11xQ==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1.0.2",
"@sinonjs/formatio": "^3.0.0",
"@sinonjs/samsam": "^2.1.1",
"@sinonjs/samsam": "^2.1.2",
"diff": "^3.5.0",
"lodash.get": "^4.4.2",
"lolex": "^2.7.4",
"lolex": "^2.7.5",
"nise": "^1.4.5",
"supports-color": "^5.5.0",
"type-detect": "^4.0.8"
}
},
"sister": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sister/-/sister-3.0.1.tgz",
"integrity": "sha512-aG41gNRHRRxPq52MpX4vtm9tapnr6ENmHUx8LMAJWCOplEMwXzh/dp5WIo52Wl8Zlc/VUyHLJ2snX0ck+Nma9g=="
},
"sisteransi": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-0.1.1.tgz",
@ -17150,7 +17182,8 @@
"text-encoding": {
"version": "0.6.4",
"resolved": "http://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz",
"integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk="
"integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=",
"dev": true
},
"text-table": {
"version": "0.2.0",
@ -18953,6 +18986,26 @@
"resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
"integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
},
"youtube-player": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.1.tgz",
"integrity": "sha512-1d62W9She0B1uKNyY6K7jtWFbOW3dYsm6hyKzrh11BLOuYFzkt8K6AcQ3QdPF3aU47dzhZ82clzOJVVWus4HTw==",
"requires": {
"debug": "^2.6.6",
"load-script": "^1.0.0",
"sister": "^3.0.0"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
}
}
},
"yurnalist": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/yurnalist/-/yurnalist-0.2.1.tgz",

View File

@ -50,6 +50,7 @@
"react-reflex": "^2.2.9",
"react-spinkit": "^3.0.0",
"react-stripe-elements": "^2.0.1",
"react-youtube": "^7.8.0",
"redux": "^4.0.0",
"redux-actions": "^2.6.1",
"redux-devtools-extension": "^2.13.5",
@ -58,7 +59,6 @@
"redux-saga": "^0.16.0",
"reselect": "^3.0.1",
"rxjs": "^6.3.3",
"sinon": "^6.3.4",
"store": "^2.0.12",
"validator": "^10.7.0",
"webpack-remove-serviceworker-plugin": "^1.0.0"
@ -88,6 +88,7 @@
"prettier": "^1.14.2",
"prettier-eslint-cli": "^4.7.1",
"react-test-renderer": "^16.5.2",
"sinon": "^6.3.5",
"webpack-cli": "^3.1.1"
},
"repository": {

View File

@ -0,0 +1,218 @@
{
"name": "fcc-create-nav-data",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
"integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"browser-stdout": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
"integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="
},
"chai": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz",
"integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==",
"requires": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.2",
"deep-eql": "^3.0.1",
"get-func-name": "^2.0.0",
"pathval": "^1.1.0",
"type-detect": "^4.0.5"
}
},
"check-error": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
"integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII="
},
"commander": {
"version": "2.15.1",
"resolved": "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
"integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag=="
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
},
"deep-eql": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
"integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
"requires": {
"type-detect": "^4.0.0"
}
},
"diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA=="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"get-func-name": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
"integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE="
},
"glob": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"growl": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
"integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA=="
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
},
"he": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
"integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
},
"mkdirp": {
"version": "0.5.1",
"resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"requires": {
"minimist": "0.0.8"
}
},
"mocha": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz",
"integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==",
"requires": {
"browser-stdout": "1.3.1",
"commander": "2.15.1",
"debug": "3.1.0",
"diff": "3.5.0",
"escape-string-regexp": "1.0.5",
"glob": "7.1.2",
"growl": "1.10.5",
"he": "1.1.1",
"minimatch": "3.0.4",
"mkdirp": "0.5.1",
"supports-color": "5.4.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"requires": {
"wrappy": "1"
}
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"pathval": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz",
"integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA="
},
"supports-color": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
"integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==",
"requires": {
"has-flag": "^3.0.0"
}
},
"type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
}
}
}

View File

@ -1,170 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
assertion-error@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
browser-stdout@1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
chai@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c"
dependencies:
assertion-error "^1.0.1"
check-error "^1.0.1"
deep-eql "^3.0.0"
get-func-name "^2.0.0"
pathval "^1.0.0"
type-detect "^4.0.0"
check-error@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
commander@2.11.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
debug@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
dependencies:
ms "2.0.0"
deep-eql@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
dependencies:
type-detect "^4.0.0"
diff@3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
escape-string-regexp@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
get-func-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
glob@7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
growl@1.10.3:
version "1.10.3"
resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.3.tgz#1926ba90cf3edfe2adb4927f5880bc22c66c790f"
has-flag@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
he@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies:
brace-expansion "^1.1.7"
minimist@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
mkdirp@0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
dependencies:
minimist "0.0.8"
mocha@^5.0.5:
version "5.0.5"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.0.5.tgz#e228e3386b9387a4710007a641f127b00be44b52"
dependencies:
browser-stdout "1.3.1"
commander "2.11.0"
debug "3.1.0"
diff "3.5.0"
escape-string-regexp "1.0.5"
glob "7.1.2"
growl "1.10.3"
he "1.1.1"
mkdirp "0.5.1"
supports-color "4.4.0"
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
dependencies:
wrappy "1"
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
pathval@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
supports-color@4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e"
dependencies:
has-flag "^2.0.0"
type-detect@^4.0.0:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"

View File

@ -0,0 +1,26 @@
const crypto = require('crypto');
function createNewsNode(article) {
const contentDigest = crypto
.createHash('md5')
.update(JSON.stringify(article))
.digest('hex');
const internal = {
contentDigest,
type: 'NewsArticleNode'
};
return JSON.parse(
JSON.stringify({
...article,
id: article._id + ' >>>>>>> ' + internal.type,
children: [],
parent: null,
internal,
sourceInstanceName: 'article'
})
);
}
exports.createNewsNode = createNewsNode;

View File

@ -0,0 +1,55 @@
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') });
const { MongoClient } = require('mongodb');
const { createNewsNode } = require('./create-news-node');
const { db } = require('../../../config/secrets');
exports.sourceNodes = function sourceChallengesSourceNodes(
{ actions, reporter },
pluginOptions
) {
function handleError(err, client, reject) {
if (err) {
if (client) {
client.close();
}
reject(err);
reporter.panic(err);
}
}
const { maximumStaticRenderCount = 100 } = pluginOptions;
const { createNode } = actions;
return new Promise((resolve, reject) =>
MongoClient.connect(
db,
{ useNewUrlParser: true },
async function(err, client) {
handleError(err, client, reject);
reporter.info('fcc-source-news connected successfully to mongo');
const db = client.db('freecodecamp');
const articleCollection = db.collection('article');
articleCollection
.aggregate([
{ $match: { featured: true } },
{ $sort: { firstPublishedDate: -1 } },
{ $limit: maximumStaticRenderCount }
])
.toArray((err, articles) => {
handleError(err, client, reject);
articles
.map(article => createNewsNode(article))
.map(node => createNode(node));
client.close();
reporter.info('fcc-source-news mongo connection closed');
return resolve();
});
}
)
);
};

View File

@ -0,0 +1,80 @@
{
"name": "fcc-source-news",
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"bson": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.0.tgz",
"integrity": "sha512-9Aeai9TacfNtWXOYarkFJRW2CWo+dRon+fuLZYJmvLV3+MiUp0bEI6IAZfXEIg7/Pl/7IWlLaDnhzTsD81etQA=="
},
"memory-pager": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.1.0.tgz",
"integrity": "sha512-Mf9OHV/Y7h6YWDxTzX/b4ZZ4oh9NSXblQL8dtPCOomOtZciEHxePR78+uHFLLlsk01A6jVHhHsQZZ/WcIPpnzg==",
"optional": true
},
"mongodb": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.9.tgz",
"integrity": "sha512-f+Og32wK/ovzVlC1S6Ft7yjVTvNsAOs6pBpDrPd2/3wPO9ijNsQrTNntuECjOSxGZpPVl0aRqgHzF1e9e+KvnQ==",
"requires": {
"mongodb-core": "3.1.8",
"safe-buffer": "^5.1.2"
}
},
"mongodb-core": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.1.8.tgz",
"integrity": "sha512-reWCqIRNehyuLaqaz5JMOmh3Xd8JIjNX34o8mnewXLK2Fyt/Ky6BZbU+X0OPzy8qbX+JZrOtnuay7ASCieTYZw==",
"requires": {
"bson": "^1.1.0",
"require_optional": "^1.0.1",
"safe-buffer": "^5.1.2",
"saslprep": "^1.0.0"
}
},
"require_optional": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz",
"integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==",
"requires": {
"resolve-from": "^2.0.0",
"semver": "^5.1.0"
}
},
"resolve-from": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"saslprep": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.2.tgz",
"integrity": "sha512-4cDsYuAjXssUSjxHKRe4DTZC0agDwsCqcMqtJAQPzC74nJ7LfAJflAtC1Zed5hMzEQKj82d3tuzqdGNRsLJ4Gw==",
"optional": true,
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"semver": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
"integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg=="
},
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
"optional": true,
"requires": {
"memory-pager": "^1.0.2"
}
}
}
}

View File

@ -0,0 +1,6 @@
{
"name": "fcc-source-news",
"dependencies": {
"mongodb": "^3.1.9"
}
}

View File

@ -0,0 +1,16 @@
export const slugWithId = '/news/quincy/an-article-title--abcDEF123';
export const slugWithIdAndQuery =
'/news/quincy/an-article-title--abcDEF123?some=query';
export const slugWithIdAndHash =
'/news/quincy/an-article-title--abcDEF123#top-of-page';
export const slugWithIdAndTrailingSlash =
'/news/quincy/an-article-title--abcDEF123/';
export const mockId = 'abcDEF123';
export const slugWithoutId = '/news/quincy/an-article-title';
export const mockArguments = {
username: 'quincy',
slugPart: 'an-article-title',
shortId: 'abcDEF123'
};

View File

@ -0,0 +1,42 @@
/* global describe it expect */
import {
wrapHandledError,
unwrapHandledError
} from '../../utils/handled-error';
describe('handled-error integration', () => {
const handledA = {
type: 'info',
message: 'something helpful',
redirectTo: '/a-path-we-choose'
};
const handledB = {
type: 'danger',
message: 'Oh noes!',
redirectTo: '/whoops'
};
const handledC = {
type: 'success',
message: 'great news!',
redirectTo: '/awesome'
};
const handledD = {};
it('can wrap and unwrap handled errors', () => {
expect.assertions(4);
const wrappedA = wrapHandledError(new Error(), handledA);
const wrappedB = wrapHandledError(new Error(), handledB);
const wrappedC = wrapHandledError(new Error(), handledC);
const wrappedD = wrapHandledError(new Error(), handledD);
const unwrappedA = unwrapHandledError(wrappedA);
const unwrappedB = unwrapHandledError(wrappedB);
const unwrappedC = unwrapHandledError(wrappedC);
const unwrappedD = unwrapHandledError(wrappedD);
expect(unwrappedA).toEqual(handledA);
expect(unwrappedB).toEqual(handledB);
expect(unwrappedC).toEqual(handledC);
expect(unwrappedD).toEqual(handledD);
});
});

View File

@ -0,0 +1,33 @@
/* global describe it expect */
import faker from 'faker';
import { kebabCase } from 'lodash';
import shortid from 'shortid';
import { createArticleSlug } from '../../../utils/news';
import { getShortIdFromSlug } from '../../utils';
shortid.characters(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$+'
);
const shortId = () => shortid.generate();
describe('news-slug integration', () => {
it('returns the correct id from a generated slug', () => {
expect.assertions(100);
const generatedArguments = Array(100)
.fill(null)
.map(() => ({
username: faker.internet.userName(),
slugPart: kebabCase(faker.lorem.sentence()).toLowerCase(),
shortId: shortId()
}));
return generatedArguments.forEach(arg => {
const { shortId } = arg;
const generatedSlug = createArticleSlug(arg);
const extractedId = getShortIdFromSlug(generatedSlug);
return expect(extractedId).toEqual(shortId);
});
});
});

View File

@ -0,0 +1,89 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { isNull, pick, isEmpty } from 'lodash';
import Layout from '../components/layouts/Default';
import Loader from '../components/helpers/Loader';
import { getShortIdFromSlug } from '../utils';
import { createArticleSlug } from '../../utils/news';
import {
resolveShortId,
resolveShortIdFetchStateSelector,
dynamicArticleByIdMapSelector
} from '../templates/News/redux';
import { createFlashMessage } from '../components/Flash/redux';
import ShowArticle from '../templates/News/ShowArticle';
const mapStateToProps = () => (state, { articleSlug = '' }) => {
const shortId = getShortIdFromSlug(articleSlug);
const articleMap = dynamicArticleByIdMapSelector(state);
const article = articleMap[shortId] || null;
const fetchState = resolveShortIdFetchStateSelector(state);
return { article, fetchState, shortId };
};
const mapDispatchToProps = dispatch =>
bindActionCreators({ createFlashMessage, resolveShortId }, dispatch);
class DynamicNewsArticle extends Component {
componentDidMount() {
const { shortId, article, resolveShortId } = this.props;
if (isNull(article)) {
return resolveShortId(shortId);
}
return null;
}
getArticleAsGatsbyProps(article) {
const {
author: { username },
slugPart,
shortId,
meta: { readTime }
} = article;
return {
data: {
newsArticleNode: {
...pick(article, [
'title',
'renderableContent',
'youtube',
'author',
'firstPublishedDate',
'shortId',
'featureImage'
]),
fields: { slug: createArticleSlug({ username, slugPart, shortId }) },
meta: { readTime }
}
}
};
}
render() {
const {
fetchState: { pending },
article
} = this.props;
if (pending) {
return (
<Layout>
<div className='loader-wrapper'>
<Loader />
</div>
</Layout>
);
}
return isEmpty(article) ? null : (
<ShowArticle {...this.getArticleAsGatsbyProps(article)} />
);
}
}
DynamicNewsArticle.displayName = 'DynamicNewsArticle';
export default connect(
mapStateToProps,
mapDispatchToProps
)(DynamicNewsArticle);

View File

@ -23,8 +23,7 @@ const propTypes = {
username: PropTypes.string,
profileUI: PropTypes.object
}),
showLoading: PropTypes.bool,
splat: PropTypes.string
showLoading: PropTypes.bool
};
const createRequestedUserSelector = () => (state, { maybeUser }) =>
@ -49,22 +48,15 @@ const mapDispatchToProps = dispatch =>
class ShowFourOhFour extends Component {
componentDidMount() {
const { requestedUser, maybeUser, splat, fetchProfileForUser } = this.props;
if (!splat && isEmpty(requestedUser)) {
console.log(requestedUser);
const { requestedUser, maybeUser, fetchProfileForUser } = this.props;
if (isEmpty(requestedUser)) {
return fetchProfileForUser(maybeUser);
}
return null;
}
render() {
const { isSessionUser, requestedUser, showLoading, splat } = this.props;
if (splat) {
// the uri path for this component is /:maybeUser/:splat
// if splat is defined then we on a route that is not a profile
// and we should just 404
return <FourOhFourPage />;
}
const { isSessionUser, requestedUser, showLoading } = this.props;
if (showLoading) {
// We don't know if /:maybeUser is a user or not, we will show the loader
// until we get a response from the API

View File

@ -3,7 +3,7 @@ import nanoId from 'nanoid';
import { createTypes } from '../../../utils/createTypes';
const ns = 'flash';
export const ns = 'flash';
const initialState = {
messages: []

View File

@ -7,7 +7,6 @@ header {
#top-nav {
background: #006400;
margin-bottom: 0.45rem;
height: 38px;
margin-bottom: 0px;
border-radius: 0;
@ -42,7 +41,8 @@ header {
justify-content: space-around;
}
#top-right-nav a, #top-right-nav img {
#top-right-nav a,
#top-right-nav img {
max-height: 40px;
}
@ -62,15 +62,43 @@ header {
justify-content: center;
align-items: center;
height: 100%;
margin: 0 3px;
margin: 0 5px;
}
#top-right-nav li > a, #top-right-nav li > span {
#top-right-nav li,
#top-right-nav li > a {
color: #fff;
font-size: 17px;
}
#top-right-nav li > a:hover, #top-right-nav li > a:focus {
#top-right-nav li:hover,
#top-right-nav li:hover a,
#top-right-nav li > a:hover,
#top-right-nav li:focus,
#top-right-nav li:focus a,
#top-right-nav li > a:focus {
background-color: #fff;
color: #006400;
}
li.user-state-link,
li.user-state-link:hover,
li.user-state-link:focus,
li.user-state-link > a,
li.user-state-link > a:hover,
li.user-state-link > a:focus {
background-color: #006400 !important;
}
#top-right-nav {
margin-left: 20px;
}
#top-right-nav li:last-child {
margin-right: 30px;
}
#top-right-nav li > a:hover,
#top-right-nav li > a:focus {
text-decoration: none;
}

View File

@ -30,6 +30,9 @@ function Header({ disableSettings }) {
</a>
</li>
<li>
<Link to='/news'>News</Link>
</li>
<li className='user-state-link'>
<UserState disableSettings={disableSettings} />
</li>
</ul>

View File

@ -2,7 +2,7 @@ import { createAction, handleActions } from 'redux-actions';
import { createTypes } from '../../../../utils/stateManagement';
const ns = 'curriculumMap';
export const ns = 'curriculumMap';
export const getNS = () => ns;

View File

@ -1,10 +1,3 @@
import { navigate } from 'gatsby';
import createRedirect from './createRedirect';
const RedirectHome = () => {
if (typeof window !== 'undefined') {
navigate('/');
}
return null;
};
export default RedirectHome;
export default createRedirect('/');

View File

@ -0,0 +1,3 @@
import createRedirect from './createRedirect';
export default createRedirect('/news');

View File

@ -0,0 +1,10 @@
import { navigate } from 'gatsby';
const createRedirect = (to = '/') => () => {
if (typeof window !== 'undefined') {
navigate(to);
}
return null;
};
export default createRedirect;

View File

@ -1,13 +1,20 @@
import React from 'react';
import { Router } from '@reach/router';
// eslint-disable-next-line max-len
import NotFoundPage from '../components/FourOhFour';
import RedirectNews from '../components/RedirectNews';
/* eslint-disable max-len */
import ShowProfileOrFourOhFour from '../client-only-routes/ShowProfileOrFourOhFour';
import ShowDynamicNewsOrFourOhFour from '../client-only-routes/ShowDynamicNewsOrFourOhFour';
/* eslint-enable max-len */
function FourOhFourPage() {
return (
<Router>
<ShowProfileOrFourOhFour path='/:maybeUser/:splat' />
<ShowProfileOrFourOhFour path='/:maybeUser' />
<ShowDynamicNewsOrFourOhFour path='/news/:author/:articleSlug' />
<RedirectNews path='/news/:author' />
<NotFoundPage default={true} />
</Router>
);
}

15
client/src/pages/n.js Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import { Router } from '@reach/router';
import NewsReferalLinkHandler from '../templates/News/NewsReferalLinkHandler';
import RedirectNews from '../components/RedirectNews';
export default function NewsReferalLinkRouter() {
return (
<Router>
<NewsReferalLinkHandler path='/n/:shortId' />
<RedirectNews path='/n/:shortId/:splat' />
<RedirectNews path='/n' />
</Router>
);
}

96
client/src/pages/news.js Normal file
View File

@ -0,0 +1,96 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Grid } from '@freecodecamp/react-bootstrap';
import { graphql } from 'gatsby';
import Layout from '../components/layouts/Default';
import FullWidthRow from '../components/helpers/FullWidthRow';
import Featured from '../templates/News/Featured';
const propTypes = {
data: PropTypes.shape({
allNewsArticleNode: PropTypes.shape({
edges: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string,
shortId: PropTypes.string,
slugPart: PropTypes.string,
featureImage: PropTypes.shape({
src: PropTypes.string,
alt: PropTypes.string,
caption: PropTypes.string
}),
meta: PropTypes.shape({
readTime: PropTypes.number
}),
author: PropTypes.shape({
name: PropTypes.string,
avatar: PropTypes.string,
twitter: PropTypes.string,
username: PropTypes.string,
bio: PropTypes.string
}),
viewCount: PropTypes.number,
firstPublishedDate: PropTypes.string
})
)
})
})
};
export default function NewsIndexPage(props) {
const {
allNewsArticleNode: { edges }
} = props.data;
const articles = edges.map(({ node }) => node);
return (
<Layout>
<Grid>
<FullWidthRow>
<h1>News - freeCodeCamp.org</h1>
</FullWidthRow>
<FullWidthRow>
<Featured featuredList={articles} />
</FullWidthRow>
</Grid>
</Layout>
);
}
export const query = graphql`
{
allNewsArticleNode(sort: { fields: firstPublishedDate, order: DESC }) {
edges {
node {
title
shortId
slugPart
featureImage {
src
alt
caption
}
meta {
readTime
}
author {
name
avatar
twitter
bio
username
}
viewCount
firstPublishedDate
fields {
slug
}
}
}
}
}
`;
NewsIndexPage.displayName = 'NewsIndexPage';
NewsIndexPage.propTypes = propTypes;

View File

@ -0,0 +1,25 @@
import { navigate } from 'gatsby';
import { takeEvery, put } from 'redux-saga/effects';
import { isError } from 'lodash';
import { isHandledError, unwrapHandledError } from '../utils/handled-error';
import { reportClientSideError } from '../utils/report-error';
import { createFlashMessage } from '../components/Flash/redux';
import reportedErrorMessage from '../utils/reportedErrorMessage';
const errorActionSelector = action => isError(action.payload);
function* errorHandlerSaga({ payload: error }) {
if (isHandledError(error)) {
const { type, message, redirectTo } = unwrapHandledError(error);
if (redirectTo) {
navigate(redirectTo);
}
yield put(createFlashMessage({ type, message }));
return;
}
reportClientSideError('Unhandled Error caught in error-saga', error);
yield put(createFlashMessage(reportedErrorMessage));
}
export default [takeEvery(errorActionSelector, errorHandlerSaga)];

View File

@ -19,9 +19,9 @@ import { types as settingsTypes } from './settings';
const challengeReduxTypes = {};
/** ***********************************/
const ns = 'app';
export const ns = 'app';
const defaultFetchState = {
export const defaultFetchState = {
pending: true,
complete: false,
errored: false,
@ -220,16 +220,15 @@ export const reducer = handleActions(
[username]: { ...previousUserObject, ...user }
},
userProfileFetchState: {
...defaultFetchState,
pending: false,
complete: true,
errored: false,
error: null
complete: true
}
};
},
[types.fetchProfileForUserError]: (state, { payload }) => ({
...state,
userFetchState: {
userProfileFetchState: {
pending: false,
complete: false,
errored: true,
@ -253,10 +252,9 @@ export const reducer = handleActions(
...state,
showCert: payload,
showCertFetchState: {
...defaultFetchState,
pending: false,
complete: true,
errored: false,
error: null
complete: true
}
}),
[types.showCertError]: (state, { payload }) => ({

View File

@ -1,17 +1,28 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import { reducer as app } from './';
import { reducer as flash } from '../components/Flash/redux';
import { reducer as settings } from './settings';
import { reducer as curriculumMap } from '../components/Map/redux';
import { reducer as challenge } from '../templates/Challenges/redux';
import { reducer as app, ns as appNameSpace } from './';
import {
reducer as flash,
ns as flashNameSpace
} from '../components/Flash/redux';
import { reducer as settings, ns as settingsNameSpace } from './settings';
import {
reducer as curriculumMap,
ns as curriculumMapNameSpace
} from '../components/Map/redux';
import {
reducer as challenge,
ns as challengeNameSpace
} from '../templates/Challenges/redux';
import { reducer as news, ns as newsNameSpace } from '../templates/News/redux';
export default combineReducers({
app,
challenge,
curriculumMap,
flash,
[appNameSpace]: app,
[challengeNameSpace]: challenge,
[curriculumMapNameSpace]: curriculumMap,
[flashNameSpace]: flash,
form: formReducer,
settings
[newsNameSpace]: news,
[settingsNameSpace]: settings
});

View File

@ -1,9 +1,17 @@
import { all } from 'redux-saga/effects';
import errorSagas from './error-saga';
import { sagas as appSagas } from './';
import { sagas as settingsSagas } from './settings';
import { sagas as challengeSagas } from '../templates/Challenges/redux';
import { sagas as newsSagas } from '../templates/News/redux';
import { sagas as settingsSagas } from './settings';
export default function* rootSaga() {
yield all([...appSagas, ...challengeSagas, ...settingsSagas]);
yield all([
...errorSagas,
...appSagas,
...challengeSagas,
...newsSagas,
...settingsSagas
]);
}

View File

@ -4,7 +4,7 @@ import { createTypes, createAsyncTypes } from '../../utils/createTypes';
import { createSettingsSagas } from './settings-sagas';
import { createUpdateMyEmailSaga } from './update-email-saga';
const ns = 'settings';
export const ns = 'settings';
const defaultFetchState = {
pending: false,

View File

@ -15,7 +15,7 @@ import currentChallengeEpic from './current-challenge-epic';
import { createIdToNameMapSaga } from './id-to-name-map-saga';
const ns = 'challenge';
export const ns = 'challenge';
export const backendNS = 'backendChallenge';
const initialState = {

View File

@ -0,0 +1,62 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { navigate } from 'gatsby';
import { Image } from '@freecodecamp/react-bootstrap';
import Spacer from '../../../components/helpers/Spacer';
import BannerWide from '../components/BannerWide';
import ArticleMeta from '../components/ArticleMeta';
import './featured.css';
const propTypes = {
featuredList: PropTypes.arrayOf(PropTypes.object)
};
class Featured extends Component {
createHandleArticleClick(slug) {
return e => {
e.preventDefault();
return navigate(slug);
};
}
renderFeatured(articles) {
return articles.map(article => {
const { featureImage, shortId, title, fields: {slug} } = article;
return (
<li className='featured-list-item' key={shortId}>
<a
href={'/news' + slug}
onClick={this.createHandleArticleClick(slug)}
>
<h3 className='title'>{title}</h3>
{featureImage && featureImage.src ? (
<Image
className='featured-list-image'
responsive={true}
src={featureImage.src}
/>
) : (
<BannerWide />
)}
<ArticleMeta article={article} />
</a>
<Spacer />
</li>
);
});
}
render() {
const { featuredList } = this.props;
return (
<ul className='featured-list'>{this.renderFeatured(featuredList)}</ul>
);
}
}
Featured.displayName = 'Featured';
Featured.propTypes = propTypes;
export default Featured;

View File

@ -0,0 +1,33 @@
.featured-list {
list-style: none;
padding-left: 0;
margin-top: 40px;
}
.featured-list-item {
padding-bottom: 20px;
}
.featured-list-item .title {
color: #333;
padding-bottom: 20px;
}
.featured-list-item a {
padding-top: 5px;
}
.featured-list-image {
margin: 0 auto;
}
.featured-list-item a:hover,
.featured-list-item a:focus {
text-decoration: none;
text-decoration-line: none;
text-decoration-color: transparaent;
}
.featured-list-item a:hover > .meta-wrapper,
.featured-list-item a:focus > .meta-wrapper {
color: #006400;
}

View File

@ -0,0 +1,61 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { navigate } from 'gatsby';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Layout from '../../../components/layouts/Default';
import { resolveShortId, dynamicArticleSelector } from '../redux';
import { Loader } from '../../../components/helpers';
const propTypes = {
redirect: PropTypes.string,
resolveShortId: PropTypes.func.isRequired,
shortId: PropTypes.string.isRequired
};
const mapStateToProps = () => (state, props) => {
const article = dynamicArticleSelector(state, props);
return {
redirect: article.redirect
};
};
const mapDispatchToProps = dispatch =>
bindActionCreators({ resolveShortId }, dispatch);
class NewsReferalLinkHandler extends Component {
componentDidMount() {
const { shortId, redirect, resolveShortId } = this.props;
if (!redirect) {
return resolveShortId(shortId);
}
return navigate(redirect);
}
componentDidUpdate() {
const { redirect } = this.props;
if (redirect) {
return navigate(redirect);
}
return null;
}
render() {
return (
<Layout>
<div className='loader-wrapper'>
<Loader />
</div>
</Layout>
);
}
}
NewsReferalLinkHandler.displayName = 'NewsReferalLinkHandler';
NewsReferalLinkHandler.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(NewsReferalLinkHandler);

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import ArticleMeta from '../../../components/ArticleMeta';
import ArticleMeta from '../../components/ArticleMeta';
const propTypes = {
article: PropTypes.shape({

View File

@ -0,0 +1,187 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import { graphql } from 'gatsby';
import Youtube from 'react-youtube';
import { Image, Grid } from '@freecodecamp/react-bootstrap';
import Layout from '../../../components/layouts/Default';
import Author from './components/Author';
import FullWidthRow from '../../../components/helpers/FullWidthRow';
import Spacer from '../../../components/helpers/Spacer';
import { postPopularityEvent } from '../../../utils/ajax';
import { newsArticleNodePropTypes } from './proptypes';
import './show-article.css';
const propTypes = {
data: PropTypes.shape({
newsArticleNode: newsArticleNodePropTypes
})
};
const youtubeOpts = {
playerVars: {
// https://developers.google.com/youtube/player_parameters
autoplay: 0
}
};
class ShowArticle extends Component {
constructor(props) {
super(props);
this.state = {
fetchState: {
pending: false,
complete: false,
errored: false,
error: null
},
currentArticle: {},
requiredArticle: ''
};
}
componentDidMount() {
window.scrollTo(0, 0);
const { shortId } = this.props.data.newsArticleNode;
return postPopularityEvent({
event: 'view',
timestamp: Date.now(),
shortId
});
}
youtubeReady(event) {
event.target.pauseVideo();
}
render() {
const {
data: {
newsArticleNode: {
title,
renderableContent,
youtubeId,
featureImage,
fields: { slug }
},
newsArticleNode
}
} = this.props;
// RegEx finds the first paragraph and groups the content
const description = renderableContent.join('').match(/<p>(.*?)<\/p>/)[1];
const pageTitle = `${title} | freeCodeCamp.org`;
return (
<Fragment>
<Helmet>
<title>{pageTitle}</title>
<link
href={`https://www.freecodecamp.org/news${slug}`}
rel='canonical'
/>
<meta
content={`https://www.freecodecamp.org/news${slug}`}
property='og:url'
/>
<meta content={pageTitle} property='og:title' />
<meta content={description} property='og:description' />
<meta content={description} name='description' />
<meta content={featureImage.src} property='og:image' />
</Helmet>
<Layout>
<article className='show-article'>
<Spacer />
<Author article={newsArticleNode} />
<Grid>
<FullWidthRow>
<h2>{title}</h2>
</FullWidthRow>
{featureImage ? (
<FullWidthRow>
<div className='feature-image-wrapper'>
<figure>
<Image
alt={featureImage.alt}
responsive={true}
src={featureImage.src}
/>
{featureImage.caption ? (
<figcaption
dangerouslySetInnerHTML={{
__html: featureImage.caption
}}
/>
) : null}
</figure>
</div>
</FullWidthRow>
) : null}
<FullWidthRow>
<Spacer />
<div
dangerouslySetInnerHTML={{
__html: renderableContent.join('')
}}
/>
</FullWidthRow>
{youtubeId ? (
<Fragment>
<div className='youtube-wrapper'>
<Youtube
onReady={this.youtubeReady}
opts={youtubeOpts}
videoId={youtubeId}
/>
</div>
<Spacer />
</Fragment>
) : null}
</Grid>
</article>
</Layout>
</Fragment>
);
}
}
ShowArticle.displayName = 'ShowArticle';
ShowArticle.propTypes = propTypes;
export default ShowArticle;
export const query = graphql`
fragment newsArticleContent on NewsArticleNode {
title
renderableContent
featureImage {
src
alt
caption
}
fields {
slug
}
author {
name
avatar
twitter
bio
username
}
meta {
readTime
}
firstPublishedDate
shortId
}
query NewsArticleById($id: String!) {
newsArticleNode(id: { eq: $id }) {
...newsArticleContent
}
}
`;

View File

@ -0,0 +1,26 @@
import PropTypes from 'prop-types';
export const newsArticleNodePropTypes = PropTypes.shape({
title: PropTypes.string,
renderableContent: PropTypes.arrayOf(PropTypes.string),
featureImage: PropTypes.shape({
src: PropTypes.string,
alt: PropTypes.string,
caption: PropTypes.string
}),
fields: PropTypes.shape({
slug: PropTypes.string
}),
author: PropTypes.shape({
name: PropTypes.string,
avatar: PropTypes.string,
twitter: PropTypes.string,
bio: PropTypes.string,
username: PropTypes.string
}),
meta: PropTypes.shape({
readTime: PropTypes.number
}),
firstPublishedDate: PropTypes.string,
shortId: PropTypes.string
});

View File

@ -0,0 +1,34 @@
.show-article figure {
display: flex;
flex-direction: column;
align-items: center;
}
.show-article figcaption > * {
font-size: 16px;
}
.show-article figcaption {
padding-top: 5px;
}
.show-article a {
text-decoration: underline;
}
.feature-image-wrapper {
padding-top: 32px;
}
.youtube-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
padding-top: 25px;
height: 0;
overflow: hidden;
}
.youtube-wrapper iframe {
position: absolute;
left: 0;
width: 100%;
height: 95%;
}

View File

@ -0,0 +1,67 @@
import { createAction, handleActions } from 'redux-actions';
import { createTypes } from '../../../../utils/stateManagement';
import { createAsyncTypes } from '../../../utils/createTypes';
import { defaultFetchState } from '../../../redux';
import { createShortIdSaga } from './shortId-saga';
export const ns = 'news';
const initialState = {
resolveShortIdFetchState: { ...defaultFetchState },
dynamicArticleByIdMap: {}
};
export const types = createTypes([...createAsyncTypes('resolveShortId')], ns);
export const sagas = [...createShortIdSaga(types)];
export const resolveShortId = createAction(types.resolveShortId);
export const resolveShortIdComplete = createAction(
types.resolveShortIdComplete,
article => {
const { slug } = article;
article.redirect = slug;
return article;
}
);
export const resolveShortIdError = createAction(types.resolveShortIdError);
export const resolveShortIdFetchStateSelector = state =>
state[ns].resolveShortIdFetchState;
export const dynamicArticleByIdMapSelector = state =>
state[ns].dynamicArticleByIdMap;
export const dynamicArticleSelector = (state, { shortId }) => {
const map = dynamicArticleByIdMapSelector(state);
return map[shortId] || {};
};
export const reducer = handleActions(
{
[types.resolveShortId]: state => ({
...state,
resolveShortIdFetchState: { ...defaultFetchState }
}),
[types.resolveShortIdComplete]: (state, { payload }) => ({
...state,
resolveShortIdFetchState: {
...defaultFetchState,
pending: false,
complete: true
},
dynamicArticleByIdMap: {
...state.dynamicArticleByIdMap,
[payload.shortId]: payload
}
}),
[types.resolveShortIdError]: (state, { payload: error }) => ({
...state,
resolveShortIdFetchState: {
...defaultFetchState,
pending: false,
errored: true,
error
}
})
},
initialState
);

View File

@ -0,0 +1,27 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import { getArticleById } from '../../../utils/ajax';
import { resolveShortIdComplete, resolveShortIdError } from './';
import { handleAPIError, wrapHandledError } from '../../../utils/handled-error';
function* fetchArticleByIdSaga({ payload }) {
try {
const { data } = yield call(getArticleById, payload);
yield put(resolveShortIdComplete(data));
} catch (e) {
const { response: { status } = {} } = e;
if (typeof status !== 'undefined') {
const handledError = wrapHandledError(
e,
handleAPIError(e, { redirectTo: '/news' })
);
yield put(resolveShortIdError(handledError));
return;
}
yield put(resolveShortIdError(e));
}
}
export function createShortIdSaga(types) {
return [takeEvery(types.resolveShortId, fetchArticleByIdSaga)];
}

View File

@ -1,4 +1,5 @@
import axios from 'axios';
import qs from 'query-string';
const base = '/internal';
@ -40,8 +41,31 @@ export function getUsernameExists(username) {
return get(`/api/users/exists?username=${username}`);
}
export function getArticleById(shortId) {
return get(
`/n/${shortId}`
);
}
export function getFeaturedList(skip = 0) {
return get(
`/api/articles?${qs.stringify({
filter: JSON.stringify({
where: { featured: true, published: true },
order: 'firstPublishedDate DESC',
limit: 10,
skip
})
})}`
);
}
/** POST **/
export function postPopularityEvent(event) {
return post('/p', event);
}
export function postReportUser(body) {
return post('/user/report-user', body);
}

View File

@ -0,0 +1,84 @@
import { has } from 'lodash';
import standardErrorMessage from './standardErrorMessage';
import reportedErrorMessage from './reportedErrorMessage';
import { reportClientSideError } from './report-error';
export const handledErrorSymbol = Symbol('handledError');
export function isHandledError(err) {
return has(err, handledErrorSymbol);
}
export function unwrapHandledError(err) {
return handledErrorSymbol in err ? err[handledErrorSymbol] : {};
}
export function wrapHandledError(err, { type, message, redirectTo }) {
err[handledErrorSymbol] = { type, message, redirectTo };
return err;
}
export function handle400Error(e, options = { redirectTo: '/welcome' }) {
const {
response: { status }
} = e;
let { redirectTo } = options;
let flash = { ...standardErrorMessage, redirectTo };
switch (status) {
case 401:
case 403: {
return {
...flash,
type: 'warn',
message: 'You are not authorised to continue on this route'
};
}
case 404: {
return {
...flash,
type: 'info',
message:
"We couldn't find what you were looking for. " +
'Please check and try again'
};
}
default: {
return flash;
}
}
}
export function handle500Error(
e,
options = {
redirectTo: '/welcome'
},
_reportClientSideError = reportClientSideError
) {
const { redirectTo } = options;
_reportClientSideError(e, 'We just handled a 5** error on the client');
return { ...reportedErrorMessage, redirectTo };
}
export function handleAPIError(
e,
options,
_reportClientSideError = reportClientSideError
) {
const { response: { status = 0 } = {} } = e;
if (status >= 400 && status < 500) {
return handle400Error(e, options);
}
if (status >= 500) {
return handle500Error(e, options, _reportClientSideError);
}
const { redirectTo } = options;
_reportClientSideError(
e,
'We just handled an api error on the client without an error status code'
);
return { ...reportedErrorMessage, redirectTo };
}

View File

@ -0,0 +1,150 @@
/* global describe it expect */
import { isObject } from 'lodash';
import sinon from 'sinon';
import {
isHandledError,
wrapHandledError,
unwrapHandledError,
handledErrorSymbol,
handleAPIError
} from './handled-error';
import reportedErrorMessage from './reportedErrorMessage';
describe('client/src utilities', () => {
describe('handled-error.js', () => {
const mockHandledErrorData = {
type: 'info',
message: 'something helpful',
redirectTo: '/a-path-we-choose'
};
describe('isHandledError', () => {
it('returns a boolean', () => {
expect(typeof isHandledError({})).toEqual('boolean');
});
it('returns false for an unhandled error', () => {
expect(isHandledError(new Error())).toEqual(false);
});
it('returns true for a handled error', () => {
const handledError = new Error();
handledError[handledErrorSymbol] = {};
expect(isHandledError(handledError)).toEqual(true);
});
});
describe('wrapHandledError', () => {
// this is testing implementation details 👎
// we need to make these tests more robust 💪
it('returns an error with a handledError property', () => {
const handledError = wrapHandledError(
new Error(),
mockHandledErrorData
);
expect(handledErrorSymbol in handledError).toEqual(true);
});
it('assigns error handling details to the handledError property', () => {
const handledError = wrapHandledError(
new Error(),
mockHandledErrorData
);
expect(handledError[handledErrorSymbol]).toEqual(mockHandledErrorData);
});
});
describe('unwrapHandledError', () => {
// this is testing implementation details 👎
// we need to make these tests more robust 💪
it('returns an object by default', () => {
const error = new Error();
const unwrappedError = unwrapHandledError(error);
expect(isObject(unwrappedError)).toBe(true);
});
it('returns the data that was wrapped in the error', () => {
const handledError = new Error();
handledError[handledErrorSymbol] = mockHandledErrorData;
const unwrapped = unwrapHandledError(handledError);
expect(unwrapped).toEqual(mockHandledErrorData);
});
});
describe('handleAPIError', () => {
let reportMock;
beforeEach(() => {
reportMock = sinon.spy();
});
it('returns handled error data', () => {
expect.assertions(3);
const axiosErrorMock = {
response: {
status: 400
}
};
const result = handleAPIError(
axiosErrorMock,
{ redirectTo: '/' },
reportMock
);
expect(result).toHaveProperty('type');
expect(result).toHaveProperty('message');
expect(result).toHaveProperty('redirectTo');
});
it('does not report 4** errors', () => {
expect.assertions(1);
for (let i = 400; i < 500; i++) {
const axiosErrorMock = {
response: {
status: i
}
};
handleAPIError(axiosErrorMock, { redirectTo: '/' }, reportMock);
}
expect(reportMock.called).toBe(false);
});
it('reports on 5** errors', () => {
const axiosErrorMock = {
response: {
status: 502
}
};
handleAPIError(axiosErrorMock, { redirectTo: '/' }, reportMock);
expect(reportMock.calledOnce).toBe(true);
});
it('returns a `reportedErrorMessage` for a 5** error', () => {
const axiosErrorMock = {
response: {
status: 502
}
};
const result = handleAPIError(
axiosErrorMock,
{ redirectTo: '/' },
reportMock
);
expect(result).toEqual({ ...reportedErrorMessage, redirectTo: '/' });
});
it('respects a `null` redirectTo', () => {
const axiosErrorMock = {
response: {
status: 400
}
};
const result = handleAPIError(
axiosErrorMock,
{ redirectTo: null },
reportMock
);
expect(result.redirectTo).toBe(null);
});
});
});
});

View File

@ -1,6 +1,22 @@
import { findIndex } from 'lodash';
// These regex are not for validation, it is purely to see
// if we are looking at something like what we want to validate
// before we try to validate
export const maybeEmailRE = /.*@.*\.\w\w/;
export const maybeUrlRE = /https?:\/\/.*\..*/;
export const hasProtocolRE = /^http/;
export const getShortIdFromSlug = (slug = '') => {
const slugToArray = [...slug];
const endOfUseableSlug = findIndex(
slugToArray,
char => char === '?' || char === '#'
);
let operableSlug = slug.slice(0);
if (endOfUseableSlug !== -1) {
operableSlug = slug.slice(0, endOfUseableSlug);
}
const [, maybeShortId = ''] = operableSlug.split('--');
return maybeShortId.replace(/\/*$/, '');
};

View File

@ -0,0 +1,7 @@
/* global Rollbar ENVIRONMENT */
export function reportClientSideError(e, message = 'Unhandled error') {
return ENVIRONMENT === 'production'
? Rollbar.error(`Client: ${message}`, e)
: console.error(`Stub Rollbar call - Client: ${message}`, e);
}

View File

@ -0,0 +1,6 @@
export default {
type: 'danger',
message:
'Something is not quite right. A report has been generated and the ' +
'freeCodeCamp.org team have been notified.'
};

View File

@ -0,0 +1,45 @@
/* global describe it expect */
import {
slugWithId,
slugWithIdAndHash,
slugWithIdAndQuery,
slugWithIdAndTrailingSlash,
slugWithoutId,
mockId
} from '../__mocks__/news-article';
import { getShortIdFromSlug } from './';
describe('client/src utilities', () => {
describe('getShortIdFromSlug', () => {
const emptyString = '';
it('returns a string', () => {
expect(typeof getShortIdFromSlug()).toEqual('string');
});
it('extracts the shortId when one is present', () => {
const result = getShortIdFromSlug(slugWithId);
expect(result).toEqual(mockId);
});
it('still returns a string when no id is found', () => {
const result = getShortIdFromSlug(slugWithoutId);
expect(result).toEqual(emptyString);
});
it('can handle query', () => {
const result = getShortIdFromSlug(slugWithIdAndQuery);
expect(result).toEqual(mockId);
});
it('can handle hash', () => {
const result = getShortIdFromSlug(slugWithIdAndHash);
expect(result).toEqual(mockId);
});
it('can handle trails slashes', () => {
const result = getShortIdFromSlug(slugWithIdAndTrailingSlash);
expect(result).toEqual(mockId);
});
});
});

View File

@ -0,0 +1,114 @@
const path = require('path');
const { dasherize } = require('..');
const { viewTypes } = require('../challengeTypes');
const backend = path.resolve(
__dirname,
'../../src/templates/Challenges/backend/Show.js'
);
const classic = path.resolve(
__dirname,
'../../src/templates/Challenges/classic/Show.js'
);
const project = path.resolve(
__dirname,
'../../src/templates/Challenges/project/Show.js'
);
const intro = path.resolve(
__dirname,
'../../src/templates/Introduction/Intro.js'
);
const superBlockIntro = path.resolve(
__dirname,
'../../src/templates/Introduction/SuperBlockIntro.js'
);
const views = {
backend,
classic,
modern: classic,
project
// quiz: Quiz
};
const getNextChallengePath = (node, index, nodeArray) => {
const next = nodeArray[index + 1];
return next ? next.node.fields.slug : '/';
};
const getTemplateComponent = challengeType => views[viewTypes[challengeType]];
const getIntroIfRequired = (node, index, nodeArray) => {
const next = nodeArray[index + 1];
const isEndOfBlock = next && next.node.challengeOrder === 0;
let nextSuperBlock = '';
let nextBlock = '';
if (next) {
const { superBlock, block } = next.node;
nextSuperBlock = superBlock;
nextBlock = block;
}
return isEndOfBlock
? `/learn/${dasherize(nextSuperBlock)}/${dasherize(nextBlock)}`
: '';
};
exports.createChallengePages = createPage => ({ node }, index, thisArray) => {
const {
fields: { slug },
required = [],
template,
challengeType,
id
} = node;
if (challengeType === 7) {
return null;
}
return createPage({
path: slug,
component: getTemplateComponent(challengeType),
context: {
challengeMeta: {
introPath: getIntroIfRequired(node, index, thisArray),
template,
required,
nextChallengePath: getNextChallengePath(node, index, thisArray),
id
},
slug
}
});
};
exports.createBlockIntroPages = createPage => edge => {
const {
fields: { slug },
frontmatter: { block }
} = edge.node;
return createPage({
path: slug,
component: intro,
context: {
block: dasherize(block),
slug
}
});
};
exports.createSuperBlockIntroPages = createPage => edge => {
const {
fields: { slug },
frontmatter: { superBlock }
} = edge.node;
return createPage({
path: slug,
component: superBlockIntro,
context: {
superBlock: dasherize(superBlock),
slug
}
});
};

View File

@ -0,0 +1,41 @@
const path = require('path');
const select = require('unist-util-select');
const { head } = require('lodash');
const { isAStubRE } = require('../regEx');
const guideArticle = path.resolve(
__dirname,
'../../src/templates/Guide/GuideArticle.js'
);
exports.createGuideArticlePages = createPage => ({
node: {
htmlAst,
excerpt,
fields: { slug },
id
}
}) => {
let meta = {};
if (!isAStubRE.test(excerpt)) {
const featureImage = head(select(htmlAst, 'element[tagName=img]'));
meta.featureImage = featureImage
? featureImage.properties.src
: 'https://s3.amazonaws.com/freecodecamp' +
'/reecodecamp-square-logo-large.jpg';
const description = head(select(htmlAst, 'element[tagName=p]'));
meta.description = description ? description.children[0].value : '';
}
return createPage({
path: `/guide${slug}`,
component: guideArticle,
context: {
id,
meta
}
});
};

View File

@ -1,154 +1,9 @@
const path = require('path');
const select = require('unist-util-select');
const { head } = require('lodash');
const challengePageCreators = require('./challengePageCreator');
const guidePageCreators = require('./guidePageCreator');
const newsPageCreators = require('./newsPageCreator');
const { dasherize } = require('..');
const { isAStubRE } = require('../regEx');
const { viewTypes } = require('../challengeTypes');
const backend = path.resolve(
__dirname,
'../../src/templates/Challenges/backend/Show.js'
);
const classic = path.resolve(
__dirname,
'../../src/templates/Challenges/classic/Show.js'
);
const project = path.resolve(
__dirname,
'../../src/templates/Challenges/project/Show.js'
);
const intro = path.resolve(
__dirname,
'../../src/templates/Introduction/Intro.js'
);
const superBlockIntro = path.resolve(
__dirname,
'../../src/templates/Introduction/SuperBlockIntro.js'
);
const guideArticle = path.resolve(
__dirname,
'../../src/templates/Guide/GuideArticle.js'
);
const views = {
backend,
classic,
modern: classic,
project
// quiz: Quiz
};
const getNextChallengePath = (node, index, nodeArray) => {
const next = nodeArray[index + 1];
return next ? next.node.fields.slug : '/';
};
const getTemplateComponent = challengeType => views[viewTypes[challengeType]];
const getIntroIfRequired = (node, index, nodeArray) => {
const next = nodeArray[index + 1];
const isEndOfBlock = next && next.node.challengeOrder === 0;
let nextSuperBlock = '';
let nextBlock = '';
if (next) {
const { superBlock, block } = next.node;
nextSuperBlock = superBlock;
nextBlock = block;
}
return isEndOfBlock
? `/learn/${dasherize(nextSuperBlock)}/${dasherize(nextBlock)}`
: '';
};
exports.createChallengePages = createPage => ({ node }, index, thisArray) => {
const {
fields: { slug },
required = [],
template,
challengeType,
id
} = node;
if (challengeType === 7) {
return null;
}
return createPage({
path: slug,
component: getTemplateComponent(challengeType),
context: {
challengeMeta: {
introPath: getIntroIfRequired(node, index, thisArray),
template,
required,
nextChallengePath: getNextChallengePath(node, index, thisArray),
id
},
slug
}
});
};
exports.createBlockIntroPages = createPage => edge => {
const {
fields: { slug },
frontmatter: { block }
} = edge.node;
return createPage({
path: slug,
component: intro,
context: {
block: dasherize(block),
slug
}
});
};
exports.createSuperBlockIntroPages = createPage => edge => {
const {
fields: { slug },
frontmatter: { superBlock }
} = edge.node;
return createPage({
path: slug,
component: superBlockIntro,
context: {
superBlock: dasherize(superBlock),
slug
}
});
};
exports.createGuideArticlePages = createPage => ({
node: {
htmlAst,
excerpt,
fields: { slug },
id
}
}) => {
let meta = {};
if (!isAStubRE.test(excerpt)) {
const featureImage = head(select(htmlAst, 'element[tagName=img]'));
meta.featureImage = featureImage
? featureImage.properties.src
: 'https://s3.amazonaws.com/freecodecamp' +
'/reecodecamp-square-logo-large.jpg';
const description = head(select(htmlAst, 'element[tagName=p]'));
meta.description = description ? description.children[0].value : '';
}
return createPage({
path: `/guide${slug}`,
component: guideArticle,
context: {
id,
meta
}
});
module.exports = {
...challengePageCreators,
...guidePageCreators,
...newsPageCreators
};

View File

@ -0,0 +1,22 @@
const path = require('path');
const newsArticle = path.resolve(
__dirname, '../../src/templates/News/ShowArticle/index.js'
);
exports.createNewsArticle = createPage => ({
node: {
fields: { slug },
shortId,
id
}
}) =>
createPage({
path: slug,
component: newsArticle,
context: {
slug,
shortId,
id
}
});

18
client/utils/news.js Normal file
View File

@ -0,0 +1,18 @@
exports.createArticleSlug = ({
username = '',
slugPart = '',
shortId = ''
} = {}) => {
if (!username || !slugPart || !shortId) {
throw new Error(`
createArtcileSlug: One or more properties were missing, all are required
{
username: ${username},
slugPart: ${slugPart},
shortId: ${shortId}
}
`);
}
return `/news/${username}/${slugPart.concat('--', shortId)}`;
};

32
client/utils/news.test.js Normal file
View File

@ -0,0 +1,32 @@
/* global describe it expect */
import { mockArguments, slugWithId} from '../src/__mocks__/news-article';
import { createArticleSlug } from './news';
describe('news utils', () => {
describe('createArticleSlug', () => {
it('returns a string', () => {
expect(typeof createArticleSlug(mockArguments)).toEqual('string');
});
it('throws when values are missing', () => {
expect.assertions(3);
/* eslint-disable no-undefined */
expect(() =>
createArticleSlug({ ...mockArguments, shortId: undefined })
).toThrow();
expect(() =>
createArticleSlug({ ...mockArguments, slugPart: undefined })
).toThrow();
expect(() =>
createArticleSlug({ ...mockArguments, username: undefined })
).toThrow();
});
it('creates a slug in the expected format', () => {
const result = createArticleSlug(mockArguments);
expect(result).toEqual(slugWithId);
});
});
});

View File

@ -27,6 +27,9 @@ const {
TWITTER_TOKEN,
TWITTER_TOKEN_SECRET,
ROLLBAR_APP_ID,
ROLLBAR_CLIENT_ID,
STRIPE_PUBLIC,
STRIPE_SECRET
} = process.env;
@ -83,6 +86,11 @@ module.exports = {
passReqToCallback: true
},
rollbar: {
appId: ROLLBAR_APP_ID,
clientId: ROLLBAR_CLIENT_ID
},
stripe: {
public: STRIPE_PUBLIC,
secret: STRIPE_SECRET

View File

@ -2,6 +2,7 @@
"packages": [
"api-server",
"client",
"client/plugins/*",
"curriculum",
"tools/challenge-md-parser",
"tools/scripts/seed"

View File

@ -1,18 +0,0 @@
import React from 'react';
import {BrowserRouter} from 'react-router-dom';
import { render } from 'react-dom';
import NewsApp from './NewsApp';
const newsMountPoint = document.getElementById('news-app-mount');
const App = (
<BrowserRouter basename='/news'>
<NewsApp />
</BrowserRouter>
);
render(
App,
newsMountPoint
);

View File

@ -1,38 +0,0 @@
import React from 'react';
import Media from 'react-media';
import { Col, Navbar, Row } from 'react-bootstrap';
import FCCSearchBar from 'react-freecodecamp-search';
import NavLogo from './components/NavLogo';
import NavLinks from './components/NavLinks';
import propTypes from './navPropTypes';
function LargeNav({ clickOnLogo }) {
return (
<Media
query='(min-width: 956px)'
render={
() => (
<Row>
<Col className='nav-component' sm={ 7 } xs={ 12 }>
<Navbar.Header>
<NavLogo clickOnLogo={ clickOnLogo } />
<FCCSearchBar />
</Navbar.Header>
</Col>
<Col className='nav-component nav-links' sm={ 5 } xs={ 0 }>
<Navbar.Collapse>
<NavLinks />
</Navbar.Collapse>
</Col>
</Row>
)
}
/>
);
}
LargeNav.displayName = 'LargeNav';
LargeNav.propTypes = propTypes;
export default LargeNav;

View File

@ -1,42 +0,0 @@
import React from 'react';
import Media from 'react-media';
import { Navbar, Row } from 'react-bootstrap';
import FCCSearchBar from 'react-freecodecamp-search';
import NavLogo from './components/NavLogo';
import NavLinks from './components/NavLinks';
import propTypes from './navPropTypes';
function MediumNav({ clickOnLogo }) {
return (
<Media
query={{ maxWidth: 955, minWidth: 751 }}
>
{
matches => matches && typeof window !== 'undefined' && (
<div>
<Row>
<Navbar.Header className='medium-nav'>
<div className='nav-component header'>
<Navbar.Toggle />
<NavLogo clickOnLogo={ clickOnLogo } />
<FCCSearchBar />
</div>
<div className='nav-component bins'/>
</Navbar.Header>
</Row>
<Row className='collapse-row'>
<Navbar.Collapse>
<NavLinks />
</Navbar.Collapse>
</Row>
</div>
)
}
</Media>
);
}
MediumNav.displayName = 'MediumNav';
MediumNav.propTypes = propTypes;
export default MediumNav;

View File

@ -1,31 +0,0 @@
import React from 'react';
import { Navbar } from 'react-bootstrap';
import LargeNav from './LargeNav';
import MediumNav from './MediumNav';
import SmallNav from './SmallNav';
const allNavs = [
LargeNav,
MediumNav,
SmallNav
];
export default function FCCNav() {
const withNavProps = Component => (
<Component
key={ Component.displayName }
/>
);
return (
<Navbar
className='nav-height'
id='navbar'
staticTop={ true }
>
{
allNavs.map(withNavProps)
}
</Navbar>
);
}

View File

@ -1,44 +0,0 @@
import React from 'react';
import Media from 'react-media';
import { Navbar, Row } from 'react-bootstrap';
import FCCSearchBar from 'react-freecodecamp-search';
import NavLogo from './components/NavLogo';
import NavLinks from './components/NavLinks';
import propTypes from './navPropTypes';
function SmallNav({ clickOnLogo }) {
return (
<Media
query='(max-width: 750px)'
>
{
matches => matches && typeof window !== 'undefined' && (
<div>
<Row>
<Navbar.Header className='small-nav'>
<div className='nav-component header'>
<Navbar.Toggle />
<NavLogo clickOnLogo={ clickOnLogo } />
</div>
<div className='nav-component bins'/>
</Navbar.Header>
</Row>
<Row className='collapse-row'>
<Navbar.Collapse>
<NavLinks>
<FCCSearchBar />
</NavLinks>
</Navbar.Collapse>
</Row>
</div>
)
}
</Media>
);
}
SmallNav.displayName = 'SmallNav';
SmallNav.propTypes = propTypes;
export default SmallNav;

View File

@ -1,38 +0,0 @@
import React from 'react';
import { NavItem, Nav } from 'react-bootstrap';
import { startCase } from 'lodash';
const urls = {
curriculum: 'https://learn.freecodecamp.org',
forum: 'https://forum.freecodecamp.org',
news: 'https://freecodecamp.org/news'
};
const Links = Object.keys(urls).map(key => (
<NavItem href={urls[key]} key={key} target='_blank'>
{startCase(key)}
</NavItem>
));
const propTypes = {};
function NavLinks() {
return (
<Nav id='nav-links' navbar={true} pullRight={true}>
{Links}
<NavItem href='/settings'>Settings</NavItem>
</Nav>
);
}
NavLinks.displayName = 'NavLinks';
NavLinks.propTypes = propTypes;
export default NavLinks;
/*
<SignUp
isInDropDown={ !isInNav }
showLoading={ showLoading }
showSignUp={ !isSignedIn }
/>
*/

View File

@ -1,41 +0,0 @@
import React from 'react';
import { NavbarBrand } from 'react-bootstrap';
import Media from 'react-media';
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
const fCCglyph = 'https://s3.amazonaws.com/freecodecamp/FFCFire.png';
const propTypes = {};
function NavLogo() {
return (
<NavbarBrand>
<a
href='/'
>
<Media query='(min-width: 350px)'>
{
matches => matches ? (
<img
alt='learn to code javascript at freeCodeCamp logo'
className='nav-logo logo'
src={ fCClogo }
/>
) : (
<img
alt='learn to code javascript at freeCodeCamp logo'
className='nav-logo logo'
src={ fCCglyph }
/>
)
}
</Media>
</a>
</NavbarBrand>
);
}
NavLogo.displayName = 'NavLogo';
NavLogo.propTypes = propTypes;
export default NavLogo;

View File

@ -1 +0,0 @@
export { default } from './Nav';

View File

@ -1,3 +0,0 @@
// import PropTypes from 'prop-types';
export default {};

View File

@ -1,14 +0,0 @@
import React from 'react';
const propTypes = {};
function NewArticle() {
return (
<h2>New Article</h2>
);
}
NewArticle.displayName = 'NewArticle';
NewArticle.propTypes = propTypes;
export default NewArticle;

View File

@ -1 +0,0 @@
export { default } from './EditArticle';

View File

@ -1 +0,0 @@
// no-op

View File

@ -1,166 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import { Image } from 'react-bootstrap';
import Helmet from 'react-helmet';
import { getFeaturedList } from '../../utils/ajax';
import { Loader, Spacer } from '../../../common/app/helperComponents';
import BannerWide from '../../components/BannerWide';
import ArticleMeta from '../../components/ArticleMeta';
const propTypes = {
history: PropTypes.shape({
push: PropTypes.func.isRequired
})
};
const styles = `
.featured-list {
list-style: none;
padding-left: 0;
margin-top: 40px;
}
.featured-list-item {
padding-bottom: 20px;
}
.featured-list-item .title {
color: #333;
padding-bottom: 20px;
}
.featured-list-item a {
padding-top: 5px;
}
.featured-list-image {
margin: 0 auto;
}
.featured-list-item a:hover,
.featured-list-item a:focus {
text-decoration: none;
text-decoration-line: none;
text-decoration-color: transparaent;
}
.featured-list-item a:hover > .meta-wrapper,
.featured-list-item a:focus > .meta-wrapper {
color: #006400;
}
`;
class Featured extends Component {
constructor(props) {
super(props);
this.state = {
fetchState: {
pending: false,
complete: false,
errored: false,
error: null
},
featuredList: []
};
this.fetchFeaturedList = this.fetchFeaturedList.bind(this);
}
componentDidMount() {
return this.fetchFeaturedList();
}
fetchFeaturedList() {
return this.setState(
{
fetchState: { pending: true, complete: false, errored: false }
},
() =>
getFeaturedList().then(({ data }) =>
this.setState({
featuredList: data,
fetchState: {
pending: false,
complete: true,
errored: false,
error: null
}
})
)
);
}
createHandleArticleClick(slug, article) {
const { history } = this.props;
return e => {
e.preventDefault();
return history.push({ pathname: slug, state: { article } });
};
}
renderFeatured(articles) {
return articles.map(article => {
const slug = `/${article.author.username}/`.concat(
article.slugPart,
'--',
article.shortId
);
const { featureImage, shortId, title } = article;
return (
<li className='featured-list-item' key={shortId}>
<a
href={'/news' + slug}
onClick={this.createHandleArticleClick(slug, article)}
>
<h3 className='title'>{title}</h3>
{featureImage && featureImage.src ? (
<Image
className='featured-list-image'
responsive={true}
src={featureImage.src}
/>
) : (
<BannerWide />
)}
<ArticleMeta article={article} />
</a>
<Spacer />
</li>
);
});
}
render() {
const {
fetchState: { pending, complete, errored },
featuredList
} = this.state;
if (pending || !complete) {
return (
<div className='full-size' style={{ position: 'fixed', left: 0 }}>
<Loader />
</div>
);
}
if (complete && errored) {
return <h2>Oh noes!! Something went wrong!</h2>;
}
return (
<div>
<Helmet>
<style>{styles}</style>
<title>Featured | freeCodeCamp News</title>
</Helmet>
<ul className='featured-list'>{this.renderFeatured(featuredList)}</ul>
</div>
);
}
}
Featured.displayName = 'Featured';
Featured.propTypes = propTypes;
export default withRouter(Featured);

View File

@ -1,14 +0,0 @@
import React from 'react';
const propTypes = {};
function Latest() {
return (
<h2>Latest</h2>
);
}
Latest.displayName = 'Latest';
Latest.propTypes = propTypes;
export default Latest;

View File

@ -1 +0,0 @@
export { default } from './Latest';

View File

@ -1,237 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import Helmet from 'react-helmet';
import Youtube from 'react-youtube';
import { Image } from 'react-bootstrap';
import Author from './components/Author';
import { Loader, Spacer } from '../../../common/app/helperComponents';
import { getArticleById, postPopularityEvent } from '../../utils/ajax';
const propTypes = {
history: PropTypes.shape({
push: PropTypes.func.isRequired
}),
location: PropTypes.shape({
state: PropTypes.object,
pathname: PropTypes.string
}),
match: PropTypes.shape({
params: PropTypes.shape({
username: PropTypes.string,
slug: PropTypes.string
})
})
};
const youtubeOpts = {
playerVars: {
// https://developers.google.com/youtube/player_parameters
autoplay: 0
}
};
const styles = `
.show-article figure {
display: flex;
flex-direction: column;
align-items: center;
}
.show-article figcaption > * {
font-size: 16px;
}
.show-article figcaption {
padding-top: 5px;
}
.show-article a {
text-decoration: underline;
}
.feature-image-wrapper {
padding-top: 32px;
}
.youtube-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
padding-top: 25px;
height: 0;
overflow: hidden;
}
.youtube-wrapper iframe {
position: absolute;
left: 0;
width: 100%;
height: 95%;
}
`;
class ShowArticle extends Component {
constructor(props) {
super(props);
this.state = {
fetchState: {
pending: false,
complete: false,
errored: false,
error: null
},
currentArticle: {},
requiredArticle: ''
};
this.fetchArticle = this.fetchArticle.bind(this);
}
componentDidMount() {
window.scrollTo(0, 0);
const {
history,
match: {
params: { username, slug }
},
location: { state: { article } = {} }
} = this.props;
if (username && !slug) {
return history.push('/');
}
const [, shortId] = slug.split('--');
postPopularityEvent({
event: 'view',
timestamp: Date.now(),
shortId
});
if (article) {
/* eslint-disable react/no-did-mount-set-state */
return this.setState(
{
fetchState: {
complete: true
},
currentArticle: article,
requiredArticle: shortId
},
() => {
window.location.state = null;
}
);
}
return this.fetchArticle();
}
fetchArticle() {
const {
match: {
params: { slug }
}
} = this.props;
const [, shortId] = slug.split('--');
return this.setState(
{
requiredArticle: shortId,
fetchState: { pending: true, complete: false, errored: false }
},
() =>
getArticleById(shortId)
.then(({ data }) =>
this.setState({
currentArticle: data,
fetchState: {
pending: false,
complete: true,
errored: false,
error: null
}
})
)
.catch(console.error)
);
}
youtubeReady(event) {
event.target.pauseVideo();
}
render() {
const {
fetchState: { pending, complete, errored },
currentArticle: { title, renderableContent, youtubeId, featureImage },
currentArticle
} = this.state;
if (pending || !complete) {
return (
<div className='full-size' style={{ position: 'fixed', left: 0 }}>
<Loader />
</div>
);
}
if (complete && errored) {
return <h2>Oh noes!! Something went wrong!</h2>;
}
// RegEx finds the first paragraph and groups the content
const description = renderableContent.match(/<p>(.*?)<\/p>/)[1];
const slug = this.props.location.pathname;
return (
<article className='show-article'>
<Helmet>
<style>{styles}</style>
<title>{`${title} | freeCodeCamp News`}</title>
<link
href={`https://www.freecodecamp.org/news${slug}`}
rel='canonical'
/>
<meta
content={`https://www.freecodecamp.org/news${slug}`}
property='og:url'
/>
<meta content={title} property='og:title' />
<meta content={description} property='og:description' />
<meta content={description} name='description' />
<meta content={featureImage.src} property='og:image' />
</Helmet>
<Author article={currentArticle} />
<h2>{title}</h2>
<div className='feature-image-wrapper'>
<figure>
<Image
alt={featureImage.alt}
responsive={true}
src={featureImage.src}
/>
{featureImage.caption ? (
<figcaption
dangerouslySetInnerHTML={{ __html: featureImage.caption }}
/>
) : null}
</figure>
</div>
<Spacer />
<div dangerouslySetInnerHTML={{ __html: renderableContent }} />
<div className='youtube-wrapper'>
{youtubeId ? (
<Youtube
onReady={this.youtubeReady}
opts={youtubeOpts}
videoId={youtubeId}
/>
) : null}
</div>
<Spacer />
</article>
);
}
}
ShowArticle.displayName = 'ShowArticle';
ShowArticle.propTypes = propTypes;
export default withRouter(ShowArticle);

View File

@ -1 +0,0 @@
export { default } from './Show';

View File

@ -1,13 +0,0 @@
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import Show from './Show';
import Featured from './Featured';
export const routes = [
<Route component={Featured} exact={true} path='/' />,
<Route exact={true} path='/:username' render={() => <Redirect to='/' />} />,
<Route component={Show} path='/:username/:slug' />
].map(el => ({ ...el, key: el.props.path }));
// <Route component={EditArticle} path='/new' />

View File

@ -1,27 +0,0 @@
import axios from 'axios';
import qs from 'query-string';
export function getArticleById(shortId) {
return axios.get(
`/api/articles/findOne?${qs.stringify({
filter: JSON.stringify({ where: { shortId } })
})}`
);
}
export function getFeaturedList(skip = 0) {
return axios.get(
`/api/articles?${qs.stringify({
filter: JSON.stringify({
where: { featured: true, published: true },
order: 'firstPublishedDate DESC',
limit: 10,
skip
})
})}`
);
}
export function postPopularityEvent(event) {
return axios.post('/p', event);
}

View File

@ -2,6 +2,7 @@ COMPOSE_PROJECT_NAME=freecodecamp
MONGOHQ_URL='mongodb://localhost:27017/freecodecamp'
ROLLBAR_APP_ID='my-rollbar-app-id'
ROLLBAR_CLIENT_ID='post_client_id from rollbar dashboard'
AUTH0_CLIENT_ID=stuff
AUTH0_CLIENT_SECRET=stuff

View File

@ -2,13 +2,19 @@ const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../../.env') });
const MongoClient = require('mongodb').MongoClient;
const faker = require('faker');
const shortId = require('shortid');
const shortid = require('shortid');
const slugg = require('slugg');
const { homeLocation } = require('../../../config/env.json');
const debug = require('debug');
const log = debug('fcc:tools:seedNewsArticles');
shortid.characters(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$+'
);
const shortId = () => shortid.generate();
const { MONGOHQ_URL, NODE_ENV: env } = process.env;
function handleError(err, client) {
@ -61,7 +67,7 @@ const sixMonths = 15780000000;
function generateArticle() {
const now = Date.now();
const id = shortId.generate();
const id = shortId();
const title = faker.lorem.sentence();
const paragraphs = faker.random.number(10) || 1;
const arrayToLoopOver = new Array(paragraphs).fill('');
@ -79,7 +85,7 @@ function generateArticle() {
username: faker.internet.userName()
},
featureImage: {
src: faker.image.image(2000, 1300),
src: 'https://picsum.photos/2000/1300?random',
alt: faker.lorem.sentence(),
caption: paragraphs >= 5 ? faker.lorem.sentence() : ''
},
@ -92,7 +98,7 @@ function generateArticle() {
() => `<p>${faker.lorem.paragraph()}</p>`
),
published: true,
featured: true,
featured: Math.random() < 0.6,
underReview: false,
viewCount: faker.random.number(90000),
firstPublishedDate: fakeDate,

View File

@ -1,4 +1,5 @@
require('dotenv').config();
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const { spawn } = require('child_process');
const kill = require('tree-kill');