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:
Shaun Hamilton
2025-10-09 06:04:03 +02:00
committed by GitHub
parent 03d9d14d2d
commit 7c20027732
24 changed files with 844 additions and 18 deletions
@@ -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
```
+2
View File
@@ -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">&#x3C;div>Main instructions code&#x3C;/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--".'
);
});