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 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 (
|
||||
<div
|
||||
className={`challenge-instructions ${section}`}
|
||||
ref={this.instructionsRef}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: description }} />
|
||||
{instructions && (
|
||||
<Fragment>
|
||||
<hr />
|
||||
<div dangerouslySetInnerHTML={{ __html: instructions }} />
|
||||
</Fragment>
|
||||
)}
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ChallengeDescription({ description, instructions, section }) {
|
||||
return (
|
||||
<div className={`challenge-instructions ${section}`}>
|
||||
<PrismFormatted text={description} />
|
||||
{instructions && (
|
||||
<Fragment>
|
||||
<hr />
|
||||
<PrismFormatted text={instructions} />
|
||||
</Fragment>
|
||||
)}
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// 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 {
|
||||
</i>
|
||||
</div>
|
||||
<ChallengeDescription description={description} />
|
||||
<SanitizedSpan text={text} />
|
||||
<PrismFormatted text={text} />
|
||||
<Spacer />
|
||||
<div className='video-quiz-options'>
|
||||
{answers.map((option, index) => (
|
||||
@ -229,7 +229,10 @@ export class Project extends Component {
|
||||
<span className='video-quiz-selected-input'></span>
|
||||
) : null}
|
||||
</span>
|
||||
<SanitizedSpan text={option} />
|
||||
<PrismFormatted
|
||||
className={'video-quiz-option'}
|
||||
text={option}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
@ -62,3 +62,7 @@
|
||||
border-radius: 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 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;
|
||||
|
@ -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('<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', () => {
|
||||
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(
|
||||
'<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', () => {
|
||||
plugin(mockAST, file);
|
||||
expect(file.data).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match the video snapshot', () => {
|
||||
plugin(mockVideoAST, file);
|
||||
expect(file.data).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
Reference in New Issue
Block a user