mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): add challenge interactive editor (#61805)
Co-authored-by: sembauke <semboot699@gmail.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# --description--
|
||||
|
||||
This is a test file for empty interactive elements.
|
||||
|
||||
# --interactive--
|
||||
|
||||
no subsections
|
||||
@@ -0,0 +1,17 @@
|
||||
# --interactive--
|
||||
|
||||
Normal markdown
|
||||
|
||||
```html
|
||||
<div>This is NOT an interactive element</div>
|
||||
```
|
||||
|
||||
:::interactive_editor
|
||||
|
||||
```js
|
||||
console.log('Interactive JS');
|
||||
```
|
||||
|
||||
non-code md is not allowed
|
||||
|
||||
:::
|
||||
@@ -0,0 +1,43 @@
|
||||
# --interactive--
|
||||
|
||||
Normal markdown
|
||||
|
||||
```html
|
||||
<div>This is NOT an interactive element</div>
|
||||
```
|
||||
|
||||
:::interactive_editor
|
||||
|
||||
```js
|
||||
console.log('Interactive JS');
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::interactive_editor
|
||||
|
||||
```html
|
||||
<div>This is an interactive element</div>
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
```html
|
||||
<div>This is not interactive</div>
|
||||
```
|
||||
|
||||
:::interactive_editor
|
||||
|
||||
```html
|
||||
<div>This is an interactive element</div>
|
||||
```
|
||||
|
||||
```js
|
||||
console.log('Interactive JS');
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
```html
|
||||
<div>This is also not interactive</div>
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# --interactive--
|
||||
|
||||
Testing multiple JavaScript files with unique filekeys.
|
||||
|
||||
:::interactive_editor
|
||||
|
||||
```js
|
||||
console.log('First JavaScript file');
|
||||
```
|
||||
|
||||
```js
|
||||
console.log('Second JavaScript file');
|
||||
```
|
||||
|
||||
:::
|
||||
@@ -0,0 +1,33 @@
|
||||
# --description--
|
||||
|
||||
This is the main description.
|
||||
|
||||
# --instructions--
|
||||
|
||||
These are the main instructions at depth 1.
|
||||
|
||||
```html
|
||||
<div>Main instructions code</div>
|
||||
```
|
||||
|
||||
# --something-else--
|
||||
|
||||
## --instructions--
|
||||
|
||||
These are nested instructions at depth 2 that should be ignored.
|
||||
|
||||
```html
|
||||
<div>Nested instructions code</div>
|
||||
```
|
||||
|
||||
### --instructions--
|
||||
|
||||
These are nested instructions at depth 3 that should also be ignored.
|
||||
|
||||
# --hints--
|
||||
|
||||
First hint
|
||||
|
||||
```js
|
||||
// test code
|
||||
```
|
||||
@@ -18,6 +18,7 @@ const restoreDirectives = require('./plugins/restore-directives');
|
||||
const tableAndStrikeThrough = require('./plugins/table-and-strikethrough');
|
||||
const addScene = require('./plugins/add-scene');
|
||||
const addQuizzes = require('./plugins/add-quizzes');
|
||||
const addInteractiveElements = require('./plugins/add-interactive-elements');
|
||||
|
||||
// by convention, anything that adds to file.data has the name add<name>.
|
||||
const processor = unified()
|
||||
@@ -47,6 +48,7 @@ const processor = unified()
|
||||
// about.
|
||||
.use(addSeed)
|
||||
.use(addSolution)
|
||||
.use(addInteractiveElements)
|
||||
// the directives will have been parsed and used by this point, any remaining
|
||||
// 'directives' will be from text like the css selector :root. These should be
|
||||
// converted back to text before they're added to the challenge object.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const { root } = require('mdast-builder');
|
||||
const find = require('unist-util-find');
|
||||
const { isEmpty } = require('lodash');
|
||||
|
||||
const { getFilenames } = require('./utils/get-file-visitor');
|
||||
const { getSection, isMarker } = require('./utils/get-section');
|
||||
const mdastToHTML = require('./utils/mdast-to-html');
|
||||
|
||||
function plugin() {
|
||||
return transformer;
|
||||
|
||||
function transformer(tree, file) {
|
||||
const interactiveNodes = getSection(tree, `--interactive--`, 1);
|
||||
const subSection = find(root(interactiveNodes), isMarker);
|
||||
if (subSection) {
|
||||
throw Error(
|
||||
`The --interactive-- section should not have any subsections. Found subsection ${subSection.children[0].value}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEmpty(interactiveNodes)) {
|
||||
const nodules =
|
||||
interactiveNodes.map(node => {
|
||||
if (
|
||||
node.type === 'containerDirective' &&
|
||||
node.name === 'interactive_editor'
|
||||
) {
|
||||
return {
|
||||
type: 'interactiveEditor',
|
||||
data: getFiles(node.children)
|
||||
};
|
||||
} else {
|
||||
const paragraph = mdastToHTML([node]);
|
||||
return {
|
||||
type: 'paragraph',
|
||||
data: paragraph
|
||||
};
|
||||
}
|
||||
}) ?? [];
|
||||
|
||||
file.data.nodules = nodules;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFiles(filesNodes) {
|
||||
const invalidNode = filesNodes.find(node => node.type !== 'code');
|
||||
if (invalidNode) {
|
||||
throw Error('The :::interactive_editor should only contain code blocks.');
|
||||
}
|
||||
|
||||
// TODO: refactor into two steps, 1) count languages, 2) map to files
|
||||
const counts = {};
|
||||
|
||||
return filesNodes.map(node => {
|
||||
counts[node.lang] = counts[node.lang] ? counts[node.lang] + 1 : 1;
|
||||
const out = {
|
||||
contents: node.value,
|
||||
ext: node.lang,
|
||||
name:
|
||||
getFilenames(node.lang) +
|
||||
(counts[node.lang] ? `-${counts[node.lang]}` : '')
|
||||
};
|
||||
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = plugin;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, beforeEach, it, expect } from 'vitest';
|
||||
const parseFixture = require('./../__fixtures__/parse-fixture');
|
||||
const addInteractiveElements = require('./add-interactive-elements');
|
||||
|
||||
describe('add-interactive-editor plugin', () => {
|
||||
const plugin = addInteractiveElements();
|
||||
let file = { data: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
file = { data: {} };
|
||||
});
|
||||
|
||||
it('returns a function', () => {
|
||||
expect(typeof plugin).toEqual('function');
|
||||
});
|
||||
|
||||
it('adds a `nodules` property to `file.data`', async () => {
|
||||
const mockAST = await parseFixture('with-interactive.md');
|
||||
plugin(mockAST, file);
|
||||
expect(file.data).toHaveProperty('nodules');
|
||||
expect(Array.isArray(file.data.nodules)).toBe(true);
|
||||
});
|
||||
|
||||
it('populates `nodules` with editor objects', async () => {
|
||||
const mockAST = await parseFixture('with-interactive.md');
|
||||
plugin(mockAST, file);
|
||||
const editorElements = file.data.nodules.filter(
|
||||
element => element.type === 'interactiveEditor'
|
||||
);
|
||||
|
||||
expect(editorElements).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
data: [
|
||||
{
|
||||
ext: expect.any(String),
|
||||
name: expect.any(String),
|
||||
contents: expect.stringContaining(
|
||||
'<div>This is an interactive element</div>'
|
||||
)
|
||||
}
|
||||
],
|
||||
type: 'interactiveEditor'
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
expect(editorElements).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
data: [
|
||||
{
|
||||
ext: expect.any(String),
|
||||
name: expect.any(String),
|
||||
contents: expect.stringContaining(
|
||||
'This is an interactive element'
|
||||
)
|
||||
}
|
||||
],
|
||||
type: 'interactiveEditor'
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{
|
||||
ext: expect.any(String),
|
||||
name: expect.any(String),
|
||||
contents: expect.stringContaining(
|
||||
"console.log('Interactive JS');"
|
||||
)
|
||||
}
|
||||
],
|
||||
type: 'interactiveEditor'
|
||||
}
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('provides unique names for each file with the same extension', async () => {
|
||||
const mockAST = await parseFixture('with-multiple-js-files.md');
|
||||
plugin(mockAST, file);
|
||||
const editorElements = file.data.nodules.filter(
|
||||
element => element.type === 'interactiveEditor'
|
||||
);
|
||||
|
||||
expect(editorElements).toHaveLength(1);
|
||||
|
||||
const files = editorElements[0].data;
|
||||
expect(files).toHaveLength(2);
|
||||
|
||||
// Both files should be JavaScript but have unique names
|
||||
expect(files[0].ext).toBe('js');
|
||||
expect(files[1].ext).toBe('js');
|
||||
// TODO: only number if there are multiple files.
|
||||
expect(files[0].name).toBe('script-1');
|
||||
expect(files[1].name).toBe('script-2');
|
||||
|
||||
// Contents should match
|
||||
expect(files[0].contents).toBe("console.log('First JavaScript file');");
|
||||
expect(files[1].contents).toBe("console.log('Second JavaScript file');");
|
||||
});
|
||||
|
||||
it('respects the order of elements in the original markdown', async () => {
|
||||
const expectedTypes = [
|
||||
'paragraph',
|
||||
'paragraph',
|
||||
'interactiveEditor',
|
||||
'interactiveEditor',
|
||||
'paragraph',
|
||||
'interactiveEditor',
|
||||
'paragraph'
|
||||
];
|
||||
|
||||
const mockAST = await parseFixture('with-interactive.md');
|
||||
plugin(mockAST, file);
|
||||
const elements = file.data.nodules;
|
||||
const types = elements.map(element => element.type);
|
||||
|
||||
expect(types).toEqual(expectedTypes);
|
||||
});
|
||||
|
||||
it('throws if the interactive_editor directive contains non-code nodes', async () => {
|
||||
const mockAST = await parseFixture('with-interactive-non-code.md');
|
||||
expect(() => plugin(mockAST, file)).toThrow(
|
||||
'The :::interactive_editor should only contain code blocks.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,16 +10,14 @@ function addText(sectionIds) {
|
||||
}
|
||||
function transformer(tree, file) {
|
||||
for (const sectionId of sectionIds) {
|
||||
const textNodes = getSection(tree, `--${sectionId}--`);
|
||||
const textNodes = getSection(tree, `--${sectionId}--`, 1);
|
||||
const subSection = find(root(textNodes), isMarker);
|
||||
if (subSection) {
|
||||
throw Error(
|
||||
`The --${sectionId}-- section should not have any subsections. Found subsection ${subSection.children[0].value}`
|
||||
);
|
||||
}
|
||||
|
||||
const sectionText = mdastToHTML(textNodes);
|
||||
|
||||
if (!isEmpty(sectionText)) {
|
||||
file.data = {
|
||||
...file.data,
|
||||
|
||||
@@ -3,7 +3,7 @@ import parseFixture from '../__fixtures__/parse-fixture';
|
||||
import addText from './add-text';
|
||||
|
||||
describe('add-text', () => {
|
||||
let realisticAST, mockAST, withSubSectionAST;
|
||||
let realisticAST, mockAST, withSubSectionAST, withNestedInstructionsAST;
|
||||
const descriptionId = 'description';
|
||||
const instructionsId = 'instructions';
|
||||
const missingId = 'missing';
|
||||
@@ -13,6 +13,9 @@ describe('add-text', () => {
|
||||
realisticAST = await parseFixture('realistic.md');
|
||||
mockAST = await parseFixture('simple.md');
|
||||
withSubSectionAST = await parseFixture('with-subsection.md');
|
||||
withNestedInstructionsAST = await parseFixture(
|
||||
'with-nested-instructions.md'
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -134,6 +137,20 @@ describe('add-text', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore --instructions-- markers that are not at depth 1', () => {
|
||||
const plugin = addText([instructionsId]);
|
||||
plugin(withNestedInstructionsAST, file);
|
||||
|
||||
// Should only include the depth 1 instructions, not the nested ones
|
||||
const expectedText = `<section id="instructions">
|
||||
<p>These are the main instructions at depth 1.</p>
|
||||
<pre><code class="language-html"><div>Main instructions code</div>
|
||||
</code></pre>
|
||||
</section>`;
|
||||
|
||||
expect(file.data[instructionsId]).toEqual(expectedText);
|
||||
});
|
||||
|
||||
it('should have an output to match the snapshot', () => {
|
||||
const plugin = addText([descriptionId, instructionsId]);
|
||||
plugin(mockAST, file);
|
||||
|
||||
@@ -97,3 +97,4 @@ function idToData(node, index, parent, seeds) {
|
||||
}
|
||||
|
||||
module.exports.getFileVisitor = getFileVisitor;
|
||||
module.exports.getFilenames = getFilenames;
|
||||
|
||||
@@ -34,18 +34,19 @@ function _getSection(tree) {
|
||||
};
|
||||
}
|
||||
|
||||
const startNode = marker => ({
|
||||
const startNode = (marker, depth) => ({
|
||||
type: 'heading',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: marker
|
||||
}
|
||||
]
|
||||
],
|
||||
...((typeof depth === 'number' && { depth }) || {})
|
||||
});
|
||||
|
||||
function getSection(tree, marker) {
|
||||
const start = find(tree, startNode(marker));
|
||||
function getSection(tree, marker, depth) {
|
||||
const start = find(tree, startNode(marker, depth));
|
||||
return _getSection(tree)(start);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,17 @@ describe('getSection', () => {
|
||||
});
|
||||
|
||||
it('should return an array', () => {
|
||||
expect.assertions(1);
|
||||
const actual = getSection(simpleAst, '--hints--');
|
||||
expect(isArray(actual)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return an empty array if the marker is not present', () => {
|
||||
expect.assertions(2);
|
||||
const actual = getSection(simpleAst, '--not-a-marker--');
|
||||
expect(isArray(actual)).toBe(true);
|
||||
expect(actual.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include any headings without markers', () => {
|
||||
expect.assertions(1);
|
||||
const actual = getSection(extraHeadingAst, '--description--');
|
||||
expect(
|
||||
find(root(actual), {
|
||||
@@ -38,7 +35,6 @@ describe('getSection', () => {
|
||||
});
|
||||
|
||||
it('should include the rest of the AST if there is no end marker', () => {
|
||||
expect.assertions(2);
|
||||
const actual = getSection(extraHeadingAst, '--solutions--');
|
||||
expect(actual.length > 0).toBe(true);
|
||||
expect(
|
||||
@@ -46,6 +42,11 @@ describe('getSection', () => {
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore a marker if the depth is not correct', () => {
|
||||
const actual = getSection(extraHeadingAst, '--instructions--', 2);
|
||||
expect(actual).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match the hints snapshot', () => {
|
||||
const actual = getSection(simpleAst, '--hints--');
|
||||
expect(actual).toMatchSnapshot();
|
||||
|
||||
@@ -13,6 +13,7 @@ const VALID_MARKERS = [
|
||||
'# --fillInTheBlank--',
|
||||
'# --hints--',
|
||||
'# --instructions--',
|
||||
'# --interactive--',
|
||||
'# --notes--',
|
||||
'# --questions--',
|
||||
'# --quizzes--',
|
||||
|
||||
@@ -85,8 +85,8 @@ id: test
|
||||
title: Test
|
||||
---
|
||||
|
||||
## --instructions--
|
||||
Instructions should be at level 1, not 2.
|
||||
## --interactive--
|
||||
Interactive should be at level 1, not 2.
|
||||
|
||||
### --seed-contents--
|
||||
Seed contents should be at level 2, not 3.
|
||||
@@ -95,7 +95,7 @@ Seed contents should be at level 2, not 3.
|
||||
expect(() => {
|
||||
processor.runSync(processor.parse(file));
|
||||
}).toThrow(
|
||||
'Invalid heading levels: "## --instructions--" should be "# --instructions--", "### --seed-contents--" should be "## --seed-contents--".'
|
||||
'Invalid heading levels: "## --interactive--" should be "# --interactive--", "### --seed-contents--" should be "## --seed-contents--".'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ id: test
|
||||
title: Test
|
||||
---
|
||||
|
||||
## --instructions--
|
||||
## --interactive--
|
||||
Wrong level.
|
||||
|
||||
# --invalid-marker--
|
||||
@@ -118,7 +118,7 @@ Wrong level.
|
||||
expect(() => {
|
||||
processor.runSync(processor.parse(file));
|
||||
}).toThrow(
|
||||
'Invalid marker names: "--invalid-marker--".\nInvalid heading levels: "## --instructions--" should be "# --instructions--", "### --seed-contents--" should be "## --seed-contents--".'
|
||||
'Invalid marker names: "--invalid-marker--".\nInvalid heading levels: "## --interactive--" should be "# --interactive--", "### --seed-contents--" should be "## --seed-contents--".'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user