diff --git a/client/src/templates/Challenges/components/Challenge-Description.js b/client/src/templates/Challenges/components/Challenge-Description.js index cfcadc8baf..ad6fcfd819 100644 --- a/client/src/templates/Challenges/components/Challenge-Description.js +++ b/client/src/templates/Challenges/components/Challenge-Description.js @@ -1,7 +1,7 @@ -import React, { Fragment, Component } from 'react'; -import Prism from 'prismjs'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; +import PrismFormatted from './PrismFormatted'; import './challenge-description.css'; const propTypes = { @@ -10,37 +10,19 @@ const propTypes = { section: PropTypes.string }; -class ChallengeDescription extends Component { - componentDidMount() { - // Just in case 'current' has not been created, though it should have been. - if (this.instructionsRef.current) { - Prism.highlightAllUnder(this.instructionsRef.current); - } - } - - constructor(props) { - super(props); - this.instructionsRef = React.createRef(); - } - - render() { - const { description, instructions, section } = this.props; - return ( -
-
- {instructions && ( - -
-
- - )} -
-
- ); - } +function ChallengeDescription({ description, instructions, section }) { + return ( +
+ + {instructions && ( + +
+ +
+ )} +
+
+ ); } ChallengeDescription.displayName = 'ChallengeDescription'; diff --git a/client/src/templates/Challenges/components/PrismFormatted.js b/client/src/templates/Challenges/components/PrismFormatted.js new file mode 100644 index 0000000000..9c4aa4d077 --- /dev/null +++ b/client/src/templates/Challenges/components/PrismFormatted.js @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import Prism from 'prismjs'; +import PropTypes from 'prop-types'; + +const propTypes = { + className: PropTypes.string, + text: PropTypes.string.isRequired +}; + +class PrismFormatted extends Component { + componentDidMount() { + // Just in case 'current' has not been created, though it should have been. + if (this.instructionsRef.current) { + Prism.highlightAllUnder(this.instructionsRef.current); + } + } + + constructor(props) { + super(props); + this.instructionsRef = React.createRef(); + } + + render() { + const { text, className } = this.props; + return ( +
+ ); + } +} + +PrismFormatted.displayName = 'PrismFormatted'; +PrismFormatted.propTypes = propTypes; + +export default PrismFormatted; diff --git a/client/src/templates/Challenges/components/SanitizedSpan.js b/client/src/templates/Challenges/components/SanitizedSpan.js deleted file mode 100644 index 5b7e3d1094..0000000000 --- a/client/src/templates/Challenges/components/SanitizedSpan.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import sanitizeHtml from 'sanitize-html'; - -const propTypes = { - text: PropTypes.string.isRequired -}; - -function SanitizedSpan({ text = '' }) { - const sanitizedText = sanitizeHtml(text, { - allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr', 'br', 'pre'] - }); - return ; -} - -SanitizedSpan.displayName = 'SanitizedSpan'; -SanitizedSpan.propTypes = propTypes; - -export default SanitizedSpan; diff --git a/client/src/templates/Challenges/components/SanitizedSpan.test.js b/client/src/templates/Challenges/components/SanitizedSpan.test.js deleted file mode 100644 index 6bc764173f..0000000000 --- a/client/src/templates/Challenges/components/SanitizedSpan.test.js +++ /dev/null @@ -1,41 +0,0 @@ -/* global expect */ - -import React from 'react'; -import { render } from '@testing-library/react'; - -import SanitizedSpan from './SanitizedSpan'; - -describe('', () => { - it('matches the snapshot', () => { - const { container } = render( - dangerous code - more text
danger`} - /> - ); - - expect(container).toMatchSnapshot(); - }); - - it('removes scripts, images, etc', () => { - const { queryByAltText, queryByText } = render( - dangerous code - more text danger`} - /> - ); - - expect(queryByText('dangerous code', { ignore: false })).toBeNull(); - expect(queryByAltText('danger')).toBeNull(); - }); - - it('leaves in line breaks', () => { - const { container } = render( - - more text`} - /> - ); - expect(container.querySelector('br')).not.toBeNull(); - }); -}); diff --git a/client/src/templates/Challenges/components/__snapshots__/SanitizedSpan.test.js.snap b/client/src/templates/Challenges/components/__snapshots__/SanitizedSpan.test.js.snap deleted file mode 100644 index b1e3fbbde9..0000000000 --- a/client/src/templates/Challenges/components/__snapshots__/SanitizedSpan.test.js.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` matches the snapshot 1`] = ` -
- - some text - more - - text -
- -
-
-`; diff --git a/client/src/templates/Challenges/video/Show.js b/client/src/templates/Challenges/video/Show.js index 0606416cd5..3e38ccef64 100644 --- a/client/src/templates/Challenges/video/Show.js +++ b/client/src/templates/Challenges/video/Show.js @@ -10,7 +10,7 @@ import YouTube from 'react-youtube'; import { createSelector } from 'reselect'; // Local Utilities -import SanitizedSpan from '../components/SanitizedSpan'; +import PrismFormatted from '../components/PrismFormatted'; import { ChallengeNode } from '../../../redux/propTypes'; import LearnLayout from '../../../components/layouts/Learn'; import ChallengeTitle from '../components/Challenge-Title'; @@ -211,7 +211,7 @@ export class Project extends Component {
- +
{answers.map((option, index) => ( @@ -229,7 +229,10 @@ export class Project extends Component { ) : null} - + ))}
diff --git a/client/src/templates/Challenges/video/show.css b/client/src/templates/Challenges/video/show.css index eea7bda54e..ed0eda2eca 100644 --- a/client/src/templates/Challenges/video/show.css +++ b/client/src/templates/Challenges/video/show.css @@ -62,3 +62,7 @@ border-radius: 50%; transform: translate(-50%, -50%); } + +.video-quiz-option > p { + margin: 0; +} diff --git a/tools/challenge-md-parser/__snapshots__/tests-to-data.test.js.snap b/tools/challenge-md-parser/__snapshots__/tests-to-data.test.js.snap index d35e91aa65..500209cb91 100644 --- a/tools/challenge-md-parser/__snapshots__/tests-to-data.test.js.snap +++ b/tools/challenge-md-parser/__snapshots__/tests-to-data.test.js.snap @@ -10,3 +10,19 @@ Object { ], } `; + +exports[`tests-to-data plugin should match the video snapshot 1`] = ` +Object { + "question": Object { + "answers": Array [ + "

inline code

", + "

some italics

", + "

code in code tags

", + ], + "solution": 3, + "text": "

Question line one

+
  var x = 'y';
+
", + }, +} +`; diff --git a/tools/challenge-md-parser/fixtures/video-challenge-md-ast.json b/tools/challenge-md-parser/fixtures/video-challenge-md-ast.json new file mode 100644 index 0000000000..9161b038d3 --- /dev/null +++ b/tools/challenge-md-parser/fixtures/video-challenge-md-ast.json @@ -0,0 +1,197 @@ +{ + "type": "root", + "children": [ + { + "type": "yaml", + "value": "id: 5e9a093a74c4063ca6f7c151\ntitle: Jupyter Notebooks Importing and Exporting Data\nchallengeType: 11\nvideoId: k1msxD3JIxE", + "position": { + "start": { + "line": 1, + "column": 1, + "offset": 0 + }, + "end": { + "line": 6, + "column": 4, + "offset": 129 + }, + "indent": [ + 1, + 1, + 1, + 1, + 1 + ] + } + }, + { + "type": "heading", + "depth": 2, + "children": [ + { + "type": "text", + "value": "Description", + "position": { + "start": { + "line": 8, + "column": 4, + "offset": 134 + }, + "end": { + "line": 8, + "column": 15, + "offset": 145 + }, + "indent": [] + } + } + ], + "position": { + "start": { + "line": 8, + "column": 1, + "offset": 131 + }, + "end": { + "line": 8, + "column": 15, + "offset": 145 + }, + "indent": [] + } + }, + { + "type": "html", + "value": "
\n
", + "position": { + "start": { + "line": 9, + "column": 1, + "offset": 146 + }, + "end": { + "line": 10, + "column": 11, + "offset": 183 + }, + "indent": [ + 1 + ] + } + }, + { + "type": "heading", + "depth": 2, + "children": [ + { + "type": "text", + "value": "Tests", + "position": { + "start": { + "line": 12, + "column": 4, + "offset": 188 + }, + "end": { + "line": 12, + "column": 9, + "offset": 193 + }, + "indent": [] + } + } + ], + "position": { + "start": { + "line": 12, + "column": 1, + "offset": 185 + }, + "end": { + "line": 12, + "column": 9, + "offset": 193 + }, + "indent": [] + } + }, + { + "type": "html", + "value": "
", + "position": { + "start": { + "line": 13, + "column": 1, + "offset": 194 + }, + "end": { + "line": 13, + "column": 21, + "offset": 214 + }, + "indent": [] + } + }, + { + "type": "code", + "lang": "yml", + "value": "question:\n text: |\n Question line one\n ```js\n var x = 'y';\n ```\n\n answers:\n - inline `code`\n - some *italics*\n - code in code tags\n solution: 3", + "position": { + "start": { + "line": 15, + "column": 1, + "offset": 216 + }, + "end": { + "line": 28, + "column": 4, + "offset": 411 + }, + "indent": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + { + "type": "html", + "value": "
", + "position": { + "start": { + "line": 30, + "column": 1, + "offset": 413 + }, + "end": { + "line": 30, + "column": 11, + "offset": 423 + }, + "indent": [] + } + } + ], + "position": { + "start": { + "line": 1, + "column": 1, + "offset": 0 + }, + "end": { + "line": 32, + "column": 1, + "offset": 425 + } + } +} diff --git a/tools/challenge-md-parser/tests-to-data.js b/tools/challenge-md-parser/tests-to-data.js index 97d69d4239..f550bd2499 100644 --- a/tools/challenge-md-parser/tests-to-data.js +++ b/tools/challenge-md-parser/tests-to-data.js @@ -1,5 +1,20 @@ const visit = require('unist-util-visit'); const YAML = require('js-yaml'); +const unified = require('unified'); +const markdown = require('remark-parse'); +const remark2rehype = require('remark-rehype'); +const html = require('rehype-stringify'); +const raw = require('rehype-raw'); + +const processor = unified() + .use(markdown) + .use(remark2rehype, { allowDangerousHTML: true }) + .use(raw) + .use(html); + +function mdToHTML(str) { + return processor.processSync(str).toString(); +} function plugin() { return transformer; @@ -11,6 +26,12 @@ function plugin() { const { lang, value } = node; if (lang === 'yml') { const tests = YAML.load(value); + if (tests.question) { + tests.question.answers = tests.question.answers.map(answer => + mdToHTML(answer) + ); + tests.question.text = mdToHTML(tests.question.text); + } file.data = { ...file.data, ...tests @@ -21,3 +42,4 @@ function plugin() { } module.exports = plugin; +module.exports.mdToHTML = mdToHTML; diff --git a/tools/challenge-md-parser/tests-to-data.test.js b/tools/challenge-md-parser/tests-to-data.test.js index 8c5b0b6347..4bb16f8bb3 100644 --- a/tools/challenge-md-parser/tests-to-data.test.js +++ b/tools/challenge-md-parser/tests-to-data.test.js @@ -1,7 +1,23 @@ /* global describe it expect beforeEach */ const mockAST = require('./fixtures/challenge-md-ast.json'); +const mockVideoAST = require('./fixtures/video-challenge-md-ast.json'); const testsToData = require('./tests-to-data'); +const { mdToHTML } = testsToData; + +describe('mdToHTML', () => { + it('converts Markdown to HTML', () => { + // a line of text on its own is parsed as a paragraph, hence the p tags + expect(mdToHTML('*it*')).toBe('

it

'); + }); + + it('preserves code language', () => { + expect(mdToHTML('```js\n var x = "y";\n```')).toBe( + '
 var x = "y";\n
' + ); + }); +}); + describe('tests-to-data plugin', () => { const plugin = testsToData(); let file = { data: {} }; @@ -29,8 +45,40 @@ describe('tests-to-data plugin', () => { expect(testObject).toHaveProperty('text'); }); + it('should generate a question object from a video challenge AST', () => { + expect.assertions(4); + plugin(mockVideoAST, file); + const testObject = file.data.question; + expect(Object.keys(testObject).length).toBe(3); + expect(testObject).toHaveProperty('text'); + expect(testObject).toHaveProperty('solution'); + expect(testObject).toHaveProperty('answers'); + }); + + it('should convert question and answer markdown into html', () => { + plugin(mockVideoAST, file); + const testObject = file.data.question; + expect(Object.keys(testObject).length).toBe(3); + expect(testObject.text).toBe( + '

Question line one

\n' + + `
  var x = 'y';\n` +
+        '
' + ); + expect(testObject.solution).toBe(3); + expect(testObject.answers[0]).toBe('

inline code

'); + expect(testObject.answers[1]).toBe('

some italics

'); + expect(testObject.answers[2]).toBe( + '

code in code tags

' + ); + }); + it('should have an output to match the snapshot', () => { plugin(mockAST, file); expect(file.data).toMatchSnapshot(); }); + + it('should match the video snapshot', () => { + plugin(mockVideoAST, file); + expect(file.data).toMatchSnapshot(); + }); });