feat: process video question md into html (#38667)
* feat: process video question md into html * test: mdToHTML * fix: use dedicated prism component
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
04412cbf6f
commit
de0bec88a3
@ -1,7 +1,7 @@
|
|||||||
import React, { Fragment, Component } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import Prism from 'prismjs';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import PrismFormatted from './PrismFormatted';
|
||||||
import './challenge-description.css';
|
import './challenge-description.css';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
@ -10,37 +10,19 @@ const propTypes = {
|
|||||||
section: PropTypes.string
|
section: PropTypes.string
|
||||||
};
|
};
|
||||||
|
|
||||||
class ChallengeDescription extends Component {
|
function ChallengeDescription({ description, instructions, section }) {
|
||||||
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 (
|
return (
|
||||||
<div
|
<div className={`challenge-instructions ${section}`}>
|
||||||
className={`challenge-instructions ${section}`}
|
<PrismFormatted text={description} />
|
||||||
ref={this.instructionsRef}
|
|
||||||
>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: description }} />
|
|
||||||
{instructions && (
|
{instructions && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<hr />
|
<hr />
|
||||||
<div dangerouslySetInnerHTML={{ __html: instructions }} />
|
<PrismFormatted text={instructions} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
<hr />
|
<hr />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ChallengeDescription.displayName = 'ChallengeDescription';
|
ChallengeDescription.displayName = 'ChallengeDescription';
|
||||||
|
38
client/src/templates/Challenges/components/PrismFormatted.js
Normal file
38
client/src/templates/Challenges/components/PrismFormatted.js
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
dangerouslySetInnerHTML={{ __html: text }}
|
||||||
|
ref={this.instructionsRef}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PrismFormatted.displayName = 'PrismFormatted';
|
||||||
|
PrismFormatted.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default PrismFormatted;
|
@ -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 <span dangerouslySetInnerHTML={{ __html: sanitizedText }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
SanitizedSpan.displayName = 'SanitizedSpan';
|
|
||||||
SanitizedSpan.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default SanitizedSpan;
|
|
@ -1,41 +0,0 @@
|
|||||||
/* global expect */
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
|
|
||||||
import SanitizedSpan from './SanitizedSpan';
|
|
||||||
|
|
||||||
describe('<SanitizedSpan />', () => {
|
|
||||||
it('matches the snapshot', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SanitizedSpan
|
|
||||||
text={`some text <script>dangerous code</script>
|
|
||||||
more <wbr>text <br> <img alt='danger' src='https://attack.com'>`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes scripts, images, etc', () => {
|
|
||||||
const { queryByAltText, queryByText } = render(
|
|
||||||
<SanitizedSpan
|
|
||||||
text={`some text <script>dangerous code</script>
|
|
||||||
more text <img alt='danger' src='https://attack.com'>`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(queryByText('dangerous code', { ignore: false })).toBeNull();
|
|
||||||
expect(queryByAltText('danger')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves in line breaks', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SanitizedSpan
|
|
||||||
text={`some text <br>
|
|
||||||
more text`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(container.querySelector('br')).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,14 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`<SanitizedSpan /> matches the snapshot 1`] = `
|
|
||||||
<div>
|
|
||||||
<span>
|
|
||||||
some text
|
|
||||||
more
|
|
||||||
<wbr />
|
|
||||||
text
|
|
||||||
<br />
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
@ -10,7 +10,7 @@ import YouTube from 'react-youtube';
|
|||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
// Local Utilities
|
// Local Utilities
|
||||||
import SanitizedSpan from '../components/SanitizedSpan';
|
import PrismFormatted from '../components/PrismFormatted';
|
||||||
import { ChallengeNode } from '../../../redux/propTypes';
|
import { ChallengeNode } from '../../../redux/propTypes';
|
||||||
import LearnLayout from '../../../components/layouts/Learn';
|
import LearnLayout from '../../../components/layouts/Learn';
|
||||||
import ChallengeTitle from '../components/Challenge-Title';
|
import ChallengeTitle from '../components/Challenge-Title';
|
||||||
@ -211,7 +211,7 @@ export class Project extends Component {
|
|||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
<ChallengeDescription description={description} />
|
<ChallengeDescription description={description} />
|
||||||
<SanitizedSpan text={text} />
|
<PrismFormatted text={text} />
|
||||||
<Spacer />
|
<Spacer />
|
||||||
<div className='video-quiz-options'>
|
<div className='video-quiz-options'>
|
||||||
{answers.map((option, index) => (
|
{answers.map((option, index) => (
|
||||||
@ -229,7 +229,10 @@ export class Project extends Component {
|
|||||||
<span className='video-quiz-selected-input'></span>
|
<span className='video-quiz-selected-input'></span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<SanitizedSpan text={option} />
|
<PrismFormatted
|
||||||
|
className={'video-quiz-option'}
|
||||||
|
text={option}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,3 +62,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-quiz-option > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@ -10,3 +10,19 @@ Object {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`tests-to-data plugin should match the video snapshot 1`] = `
|
||||||
|
Object {
|
||||||
|
"question": Object {
|
||||||
|
"answers": Array [
|
||||||
|
"<p>inline <code>code</code></p>",
|
||||||
|
"<p>some <em>italics</em></p>",
|
||||||
|
"<p><code> code in </code> code tags</p>",
|
||||||
|
],
|
||||||
|
"solution": 3,
|
||||||
|
"text": "<p>Question line one</p>
|
||||||
|
<pre><code class=\\"language-js\\"> var x = 'y';
|
||||||
|
</code></pre>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
197
tools/challenge-md-parser/fixtures/video-challenge-md-ast.json
Normal file
197
tools/challenge-md-parser/fixtures/video-challenge-md-ast.json
Normal file
@ -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": "<section id='description'>\n</section>",
|
||||||
|
"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": "<section id='tests'>",
|
||||||
|
"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> code in </code> 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": "</section>",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,20 @@
|
|||||||
const visit = require('unist-util-visit');
|
const visit = require('unist-util-visit');
|
||||||
const YAML = require('js-yaml');
|
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() {
|
function plugin() {
|
||||||
return transformer;
|
return transformer;
|
||||||
@ -11,6 +26,12 @@ function plugin() {
|
|||||||
const { lang, value } = node;
|
const { lang, value } = node;
|
||||||
if (lang === 'yml') {
|
if (lang === 'yml') {
|
||||||
const tests = YAML.load(value);
|
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 = {
|
||||||
...file.data,
|
...file.data,
|
||||||
...tests
|
...tests
|
||||||
@ -21,3 +42,4 @@ function plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = plugin;
|
module.exports = plugin;
|
||||||
|
module.exports.mdToHTML = mdToHTML;
|
||||||
|
@ -1,7 +1,23 @@
|
|||||||
/* global describe it expect beforeEach */
|
/* global describe it expect beforeEach */
|
||||||
const mockAST = require('./fixtures/challenge-md-ast.json');
|
const mockAST = require('./fixtures/challenge-md-ast.json');
|
||||||
|
const mockVideoAST = require('./fixtures/video-challenge-md-ast.json');
|
||||||
const testsToData = require('./tests-to-data');
|
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('<p><em>it</em></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves code language', () => {
|
||||||
|
expect(mdToHTML('```js\n var x = "y";\n```')).toBe(
|
||||||
|
'<pre><code class="language-js"> var x = "y";\n</code></pre>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('tests-to-data plugin', () => {
|
describe('tests-to-data plugin', () => {
|
||||||
const plugin = testsToData();
|
const plugin = testsToData();
|
||||||
let file = { data: {} };
|
let file = { data: {} };
|
||||||
@ -29,8 +45,40 @@ describe('tests-to-data plugin', () => {
|
|||||||
expect(testObject).toHaveProperty('text');
|
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(
|
||||||
|
'<p>Question line one</p>\n' +
|
||||||
|
`<pre><code class="language-js"> var x = 'y';\n` +
|
||||||
|
'</code></pre>'
|
||||||
|
);
|
||||||
|
expect(testObject.solution).toBe(3);
|
||||||
|
expect(testObject.answers[0]).toBe('<p>inline <code>code</code></p>');
|
||||||
|
expect(testObject.answers[1]).toBe('<p>some <em>italics</em></p>');
|
||||||
|
expect(testObject.answers[2]).toBe(
|
||||||
|
'<p><code> code in </code> code tags</p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should have an output to match the snapshot', () => {
|
it('should have an output to match the snapshot', () => {
|
||||||
plugin(mockAST, file);
|
plugin(mockAST, file);
|
||||||
expect(file.data).toMatchSnapshot();
|
expect(file.data).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should match the video snapshot', () => {
|
||||||
|
plugin(mockVideoAST, file);
|
||||||
|
expect(file.data).toMatchSnapshot();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user