diff --git a/client/gatsby-node.js b/client/gatsby-node.js
index a199ed413b5..b9acd811f3d 100644
--- a/client/gatsby-node.js
+++ b/client/gatsby-node.js
@@ -324,6 +324,7 @@ exports.createSchemaCustomization = ({ actions }) => {
isPrivate: Boolean
module: String
msTrophyId: String
+ nodules: [Nodule]
notes: String
order: Int
prerequisites: [PrerequisiteChallenge]
@@ -462,6 +463,11 @@ exports.createSchemaCustomization = ({ actions }) => {
beforeAll: String
afterAll: String
}
+
+ type Nodule {
+ type: String
+ data: JSON
+ }
`;
createTypes(typeDefs);
};
diff --git a/client/package.json b/client/package.json
index 72ec63cf450..d9becbcc33c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -45,6 +45,8 @@
"@babel/preset-react": "7.23.3",
"@babel/preset-typescript": "7.23.3",
"@babel/standalone": "7.23.7",
+ "@codesandbox/sandpack-react": "2.6.9",
+ "@codesandbox/sandpack-themes": "2.0.21",
"@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-brands-svg-icons": "6.7.1",
"@fortawesome/free-solid-svg-icons": "6.7.1",
diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts
index e720bf1bf84..24b871c387b 100644
--- a/client/src/redux/prop-types.ts
+++ b/client/src/redux/prop-types.ts
@@ -172,6 +172,18 @@ export interface PrerequisiteChallenge {
slug?: string;
}
+type Nodule = ParagraphNodule | InteractiveEditorNodule;
+
+type ParagraphNodule = {
+ type: 'paragraph';
+ data: string;
+};
+
+type InteractiveEditorNodule = {
+ type: 'interactiveEditor';
+ data: { ext: Ext; name: string; contents: string }[];
+};
+
export type ChallengeNode = {
challenge: {
block: string;
@@ -184,6 +196,7 @@ export type ChallengeNode = {
demoType: 'onClick' | 'onLoad' | null;
description: string;
challengeFiles: ChallengeFiles;
+ nodules: Nodule[];
explanation: string;
fields: Fields;
fillInTheBlank: FillInTheBlank;
diff --git a/client/src/templates/Challenges/components/interactive-editor.css b/client/src/templates/Challenges/components/interactive-editor.css
new file mode 100644
index 00000000000..bb828cb3fa2
--- /dev/null
+++ b/client/src/templates/Challenges/components/interactive-editor.css
@@ -0,0 +1,13 @@
+.interactive-editor-wrapper {
+ margin-bottom: 1rem;
+ border-radius: 4px;
+}
+
+.sp-cm .cm-gutters {
+ color: var(--gray-45);
+}
+
+.sp-tab-button:hover {
+ background-color: var(--gray-10);
+ color: var(--gray-90) !important;
+}
diff --git a/client/src/templates/Challenges/components/interactive-editor.tsx b/client/src/templates/Challenges/components/interactive-editor.tsx
new file mode 100644
index 00000000000..54497d20990
--- /dev/null
+++ b/client/src/templates/Challenges/components/interactive-editor.tsx
@@ -0,0 +1,92 @@
+import React, { useMemo } from 'react';
+import { Sandpack } from '@codesandbox/sandpack-react';
+import { freeCodeCampDark } from '@codesandbox/sandpack-themes';
+import './interactive-editor.css';
+
+export interface InteractiveFile {
+ ext: string;
+ name: string;
+ contents: string;
+ fileKey?: string;
+}
+
+interface Props {
+ files: InteractiveFile[];
+}
+
+const InteractiveEditor = ({ files }: Props) => {
+ // Build Sandpack files object
+ // https://github.com/codesandbox/sandpack/tree/main/sandpack-react/src/templates
+ const spFiles = useMemo(() => {
+ const obj = {} as Record<
+ string,
+ { code: string; active?: boolean; hidden?: boolean }
+ >;
+ files.forEach(file => {
+ const ext = file.ext;
+ let path = '';
+ if (ext === 'html') path = '/index.html';
+ else if (ext === 'css') path = '/styles.css';
+ else if (ext === 'js' || ext === 'ts') path = `/index.${ext}`;
+ else if (ext === 'py')
+ return; // python not supported in sandpack vanilla template
+ else if (ext === 'jsx') path = '/App.jsx';
+ else if (ext === 'tsx') path = '/App.tsx';
+ else path = `/index.${ext}`;
+ // TODO: Consider making active file first file in markdown
+ obj[path] = { code: file.contents, active: path === '/index.html' };
+ });
+ return obj;
+ }, [files]);
+
+ function got(ext: string) {
+ return files.some(f => f.ext === ext);
+ }
+
+ const showConsole = got('js') || got('ts');
+ const freeCodeCampDarkSyntax = {
+ ...freeCodeCampDark.syntax,
+ punctuation: '#ffff00',
+ definition: '#e2777a',
+ keyword: '#569cd6'
+ };
+
+ return (
+
+
+
+ );
+};
+
+InteractiveEditor.displayName = 'InteractiveEditor';
+export default InteractiveEditor;
diff --git a/client/src/templates/Challenges/generic/show.tsx b/client/src/templates/Challenges/generic/show.tsx
index 204e34e4775..ecb7d028c47 100644
--- a/client/src/templates/Challenges/generic/show.tsx
+++ b/client/src/templates/Challenges/generic/show.tsx
@@ -10,9 +10,11 @@ import { YouTubeEvent } from 'react-youtube';
import { ObserveKeys } from 'react-hotkeys';
// Local Utilities
+import PrismFormatted from '../components/prism-formatted';
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
import ChallengeDescription from '../components/challenge-description';
+import InteractiveEditor from '../components/interactive-editor';
import Hotkeys from '../components/hotkeys';
import ChallengeTitle from '../components/challenge-title';
import VideoPlayer from '../components/video-player';
@@ -69,6 +71,17 @@ interface ShowQuizProps {
updateSolutionFormValues: () => void;
}
+function renderNodule(nodule: ChallengeNode['challenge']['nodules'][number]) {
+ switch (nodule.type) {
+ case 'paragraph':
+ return ;
+ case 'interactiveEditor':
+ return ;
+ default:
+ return null;
+ }
+}
+
const ShowGeneric = ({
challengeMounted,
data: {
@@ -79,6 +92,7 @@ const ShowGeneric = ({
block,
blockType,
description,
+ nodules,
explanation,
challengeType,
fields: { blockName, tests },
@@ -228,6 +242,12 @@ const ShowGeneric = ({
)}
+ {nodules?.map((nodule, i) => {
+ return (
+ {renderNodule(nodule)}
+ );
+ })}
+
{videoId && (
<>
@@ -332,6 +352,10 @@ export const query = graphql`
blockType
challengeType
description
+ nodules {
+ type
+ data
+ }
explanation
helpCategory
instructions
diff --git a/curriculum/challenges/english/blocks/lecture-working-with-the-dom-click-events-and-web-apis/6733697661182d357fc643d2.md b/curriculum/challenges/english/blocks/lecture-working-with-the-dom-click-events-and-web-apis/6733697661182d357fc643d2.md
index e5442a4df0e..3b6cd881b96 100644
--- a/curriculum/challenges/english/blocks/lecture-working-with-the-dom-click-events-and-web-apis/6733697661182d357fc643d2.md
+++ b/curriculum/challenges/english/blocks/lecture-working-with-the-dom-click-events-and-web-apis/6733697661182d357fc643d2.md
@@ -5,7 +5,7 @@ challengeType: 19
dashedName: what-is-the-canvas-api-and-how-does-it-work
---
-# --description--
+# --interactive--
The `Canvas` API is a powerful tool that lets you manipulate graphics right inside your JavaScript file. Everything begins with a `canvas` element in HTML. This element serves as a drawing surface that you can manipulate using the instance methods and properties of the `Canvas` API.
@@ -54,6 +54,19 @@ Once you have the 2D context, you can start drawing on the canvas.
The `Canvas` API provides several methods and properties for drawing shapes, lines, and text. One of those is the `fillStyle` property, which you can combine with the `fillRect()` method to draw a rectangle or square:
+:::interactive_editor
+
+```html
+
+
+
+
+
+
+
+
+```
+
```js
const canvas = document.getElementById("my-canvas");
@@ -66,6 +79,8 @@ ctx.fillStyle = "crimson";
ctx.fillRect(1, 1, 150, 100);
```
+:::
+
`fillRect` takes 4 number values which represent the x axis, y axis, width, and height, respectively.
There's something on the screen now. You can also draw text or even create an animation. Here's a canvas to represent text:
@@ -76,6 +91,19 @@ There's something on the screen now. You can also draw text or even create an an
To finally draw the text, pass the text into the `fillText()` method as the first argument, followed by the values for the x and y axis:
+:::interactive_editor
+
+```html
+
+
+
+
+
+
+
+
+```
+
```js
const textCanvas = document.getElementById("my-text-canvas");
@@ -91,6 +119,8 @@ textCanvasCtx.fillStyle = "crimson";
textCanvasCtx.fillText("Hello HTML Canvas!", 1, 50);
```
+:::
+
The result in the browser will be the red text `Hello HTML Canvas!`.
These's much more you can do with the `Canvas` API. For example, you can combine it with `requestAnimationFrame` to create custom animations, visualizations, games, and more.
diff --git a/curriculum/schema/challenge-schema.js b/curriculum/schema/challenge-schema.js
index 2a2ad63f292..5127d1ece93 100644
--- a/curriculum/schema/challenge-schema.js
+++ b/curriculum/schema/challenge-schema.js
@@ -182,6 +182,23 @@ const schema = Joi.object().keys({
then: Joi.string()
}),
challengeFiles: Joi.array().items(fileJoi),
+ // TODO: Consider renaming to something else. Stuff show.tsx knows how to render in order
+ nodules: Joi.array().items(
+ Joi.object().keys({
+ type: Joi.valid('paragraph', 'interactiveEditor').required(),
+ data: Joi.when('type', {
+ is: ['interactiveEditor'],
+ then: Joi.array().items(
+ Joi.object().keys({
+ ext: Joi.string().required(),
+ name: Joi.string().required(),
+ contents: Joi.string().required()
+ })
+ ),
+ otherwise: Joi.string().required()
+ })
+ })
+ ),
hasEditableBoundaries: Joi.boolean(),
helpCategory: Joi.valid(
'JavaScript',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d1ca18d5e25..5c204c4e7ed 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -312,6 +312,12 @@ importers:
'@babel/standalone':
specifier: 7.23.7
version: 7.23.7
+ '@codesandbox/sandpack-react':
+ specifier: 2.6.9
+ version: 2.6.9(react-dom@17.0.2(react@17.0.2))(react@17.0.2)
+ '@codesandbox/sandpack-themes':
+ specifier: 2.0.21
+ version: 2.0.21
'@fortawesome/fontawesome-svg-core':
specifier: 6.7.1
version: 6.7.1
@@ -2277,6 +2283,48 @@ packages:
'@bundled-es-modules/tough-cookie@0.1.6':
resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==}
+ '@codemirror/autocomplete@6.19.0':
+ resolution: {integrity: sha512-61Hfv3cF07XvUxNeC3E7jhG8XNi1Yom1G0lRC936oLnlF+jrbrv8rc/J98XlYzcsAoTVupfsf5fLej1aI8kyIg==}
+
+ '@codemirror/commands@6.8.1':
+ resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
+
+ '@codemirror/lang-css@6.3.1':
+ resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
+
+ '@codemirror/lang-html@6.4.10':
+ resolution: {integrity: sha512-h/SceTVsN5r+WE+TVP2g3KDvNoSzbSrtZXCKo4vkKdbfT5t4otuVgngGdFukOO/rwRD2++pCxoh6xD4TEVMkQA==}
+
+ '@codemirror/lang-javascript@6.2.4':
+ resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
+
+ '@codemirror/language@6.11.3':
+ resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
+
+ '@codemirror/lint@6.8.5':
+ resolution: {integrity: sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==}
+
+ '@codemirror/state@6.5.2':
+ resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
+
+ '@codemirror/view@6.38.4':
+ resolution: {integrity: sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==}
+
+ '@codesandbox/nodebox@0.1.8':
+ resolution: {integrity: sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg==}
+
+ '@codesandbox/sandpack-client@2.19.8':
+ resolution: {integrity: sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==}
+
+ '@codesandbox/sandpack-react@2.6.9':
+ resolution: {integrity: sha512-JAbpc1emb9lGdZ0zfnfQnJmU91IcH1AUOmoVevB2qwdrxeaQWy5DyKyqRaQDcMyPicXSXMUF6nvDhb0HY34ofw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18
+ react-dom: ^16.8.0 || ^17 || ^18
+
+ '@codesandbox/sandpack-themes@2.0.21':
+ resolution: {integrity: sha512-CMH/MO/dh6foPYb/3eSn2Cu/J3+1+/81Fsaj7VggICkCrmRk0qG5dmgjGAearPTnRkOGORIPHuRqwNXgw0E6YQ==}
+
'@csstools/color-helpers@5.0.2':
resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==}
engines: {node: '>=18'}
@@ -3200,6 +3248,24 @@ packages:
'@keyv/serialize@1.0.3':
resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==}
+ '@lezer/common@1.2.3':
+ resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
+
+ '@lezer/css@1.3.0':
+ resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
+
+ '@lezer/highlight@1.2.1':
+ resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
+
+ '@lezer/html@1.3.12':
+ resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
+
+ '@lezer/javascript@1.5.4':
+ resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
+
+ '@lezer/lr@1.4.2':
+ resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
+
'@lmdb/lmdb-darwin-arm64@2.5.3':
resolution: {integrity: sha512-RXwGZ/0eCqtCY8FLTM/koR60w+MXyvBUpToXiIyjOcBnC81tAlTUHrRUavCEWPI9zc9VgvpK3+cbumPyR8BSuA==}
cpu: [arm64]
@@ -3240,6 +3306,9 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
+ '@marijn/find-cluster-break@1.0.2':
+ resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+
'@mdx-js/util@2.0.0-next.8':
resolution: {integrity: sha512-T0BcXmNzEunFkuxrO8BFw44htvTPuAoKbLvTG41otyZBDV1Rs+JMddcUuaP5vXpTWtgD3grhcrPEwyx88RUumQ==}
@@ -3759,6 +3828,16 @@ packages:
'@types/react':
optional: true
+ '@react-hook/intersection-observer@3.1.2':
+ resolution: {integrity: sha512-mWU3BMkmmzyYMSuhO9wu3eJVP21N8TcgYm9bZnTrMwuM818bEk+0NRM3hP+c/TqA9Ln5C7qE53p1H0QMtzYdvQ==}
+ peerDependencies:
+ react: '>=16.8'
+
+ '@react-hook/passive-layout-effect@1.2.1':
+ resolution: {integrity: sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==}
+ peerDependencies:
+ react: '>=16.8'
+
'@redux-devtools/extension@3.3.0':
resolution: {integrity: sha512-X34S/rC8S/M1BIrkYD1mJ5f8vlH0BDqxXrs96cvxSBo4FhMdbhU+GUGsmNYov1xjSyLMHgo8NYrUG8bNX7525g==}
peerDependencies:
@@ -4269,6 +4348,9 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+ '@stitches/core@1.2.8':
+ resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==}
+
'@stripe/react-stripe-js@1.16.5':
resolution: {integrity: sha512-lVPW3IfwdacyS22pP+nBB6/GNFRRhT/4jfgAK6T2guQmtzPwJV1DogiGGaBNhiKtSY18+yS8KlHSu+PvZNclvQ==}
peerDependencies:
@@ -6208,6 +6290,9 @@ packages:
classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
+ clean-set@1.1.2:
+ resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==}
+
clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
@@ -6271,6 +6356,9 @@ packages:
codemirror@5.65.16:
resolution: {integrity: sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg==}
+ codesandbox-import-util-types@2.2.3:
+ resolution: {integrity: sha512-Qj00p60oNExthP2oR3vvXmUGjukij+rxJGuiaKM6tyUmSyimdZsqHI/TUvFFClAffk9s7hxGnQgWQ8KCce27qQ==}
+
collapse-white-space@1.0.6:
resolution: {integrity: sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==}
@@ -6517,6 +6605,9 @@ packages:
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
cross-fetch@3.1.4:
resolution: {integrity: sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==}
@@ -7223,6 +7314,9 @@ packages:
resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==}
engines: {node: '>=6'}
+ escape-carriage@1.3.1:
+ resolution: {integrity: sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==}
+
escape-goat@2.1.1:
resolution: {integrity: sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==}
engines: {node: '>=8'}
@@ -8778,6 +8872,9 @@ packages:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'}
+ intersection-observer@0.10.0:
+ resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==}
+
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -10663,6 +10760,9 @@ packages:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
+ outvariant@1.4.0:
+ resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==}
+
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
@@ -11545,6 +11645,9 @@ packages:
typescript:
optional: true
+ react-devtools-inline@4.4.0:
+ resolution: {integrity: sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ==}
+
react-dom@17.0.2:
resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==}
peerDependencies:
@@ -12554,6 +12657,9 @@ packages:
state-toggle@1.0.3:
resolution: {integrity: sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==}
+ static-browser-server@1.0.3:
+ resolution: {integrity: sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==}
+
static-extend@0.1.2:
resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==}
engines: {node: '>=0.10.0'}
@@ -12598,6 +12704,9 @@ packages:
streamx@2.22.1:
resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==}
+ strict-event-emitter@0.4.6:
+ resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==}
+
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -12754,6 +12863,9 @@ packages:
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
+ style-mod@4.1.2:
+ resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
+
style-to-object@0.3.0:
resolution: {integrity: sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==}
@@ -13703,6 +13815,9 @@ packages:
resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}
deprecated: Use your platform's native performance.now() and performance.timeOrigin.
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
w3c-xmlserializer@2.0.0:
resolution: {integrity: sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==}
engines: {node: '>=10'}
@@ -16596,6 +16711,117 @@ snapshots:
'@types/tough-cookie': 4.0.5
tough-cookie: 4.1.4
+ '@codemirror/autocomplete@6.19.0':
+ dependencies:
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ '@lezer/common': 1.2.3
+
+ '@codemirror/commands@6.8.1':
+ dependencies:
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ '@lezer/common': 1.2.3
+
+ '@codemirror/lang-css@6.3.1':
+ dependencies:
+ '@codemirror/autocomplete': 6.19.0
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@lezer/common': 1.2.3
+ '@lezer/css': 1.3.0
+
+ '@codemirror/lang-html@6.4.10':
+ dependencies:
+ '@codemirror/autocomplete': 6.19.0
+ '@codemirror/lang-css': 6.3.1
+ '@codemirror/lang-javascript': 6.2.4
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ '@lezer/common': 1.2.3
+ '@lezer/css': 1.3.0
+ '@lezer/html': 1.3.12
+
+ '@codemirror/lang-javascript@6.2.4':
+ dependencies:
+ '@codemirror/autocomplete': 6.19.0
+ '@codemirror/language': 6.11.3
+ '@codemirror/lint': 6.8.5
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ '@lezer/common': 1.2.3
+ '@lezer/javascript': 1.5.4
+
+ '@codemirror/language@6.11.3':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ '@lezer/common': 1.2.3
+ '@lezer/highlight': 1.2.1
+ '@lezer/lr': 1.4.2
+ style-mod: 4.1.2
+
+ '@codemirror/lint@6.8.5':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ crelt: 1.0.6
+
+ '@codemirror/state@6.5.2':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.2
+
+ '@codemirror/view@6.38.4':
+ dependencies:
+ '@codemirror/state': 6.5.2
+ crelt: 1.0.6
+ style-mod: 4.1.2
+ w3c-keyname: 2.2.8
+
+ '@codesandbox/nodebox@0.1.8':
+ dependencies:
+ outvariant: 1.4.3
+ strict-event-emitter: 0.4.6
+
+ '@codesandbox/sandpack-client@2.19.8':
+ dependencies:
+ '@codesandbox/nodebox': 0.1.8
+ buffer: 6.0.3
+ dequal: 2.0.3
+ mime-db: 1.54.0
+ outvariant: 1.4.0
+ static-browser-server: 1.0.3
+
+ '@codesandbox/sandpack-react@2.6.9(react-dom@17.0.2(react@17.0.2))(react@17.0.2)':
+ dependencies:
+ '@codemirror/autocomplete': 6.19.0
+ '@codemirror/commands': 6.8.1
+ '@codemirror/lang-css': 6.3.1
+ '@codemirror/lang-html': 6.4.10
+ '@codemirror/lang-javascript': 6.2.4
+ '@codemirror/language': 6.11.3
+ '@codemirror/state': 6.5.2
+ '@codemirror/view': 6.38.4
+ '@codesandbox/sandpack-client': 2.19.8
+ '@lezer/highlight': 1.2.1
+ '@react-hook/intersection-observer': 3.1.2(react@17.0.2)
+ '@stitches/core': 1.2.8
+ anser: 2.1.1
+ clean-set: 1.1.2
+ codesandbox-import-util-types: 2.2.3
+ dequal: 2.0.3
+ escape-carriage: 1.3.1
+ lz-string: 1.5.0
+ react: 17.0.2
+ react-devtools-inline: 4.4.0
+ react-dom: 17.0.2(react@17.0.2)
+ react-is: 17.0.2
+
+ '@codesandbox/sandpack-themes@2.0.21': {}
+
'@csstools/color-helpers@5.0.2': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
@@ -17437,6 +17663,34 @@ snapshots:
dependencies:
buffer: 6.0.3
+ '@lezer/common@1.2.3': {}
+
+ '@lezer/css@1.3.0':
+ dependencies:
+ '@lezer/common': 1.2.3
+ '@lezer/highlight': 1.2.1
+ '@lezer/lr': 1.4.2
+
+ '@lezer/highlight@1.2.1':
+ dependencies:
+ '@lezer/common': 1.2.3
+
+ '@lezer/html@1.3.12':
+ dependencies:
+ '@lezer/common': 1.2.3
+ '@lezer/highlight': 1.2.1
+ '@lezer/lr': 1.4.2
+
+ '@lezer/javascript@1.5.4':
+ dependencies:
+ '@lezer/common': 1.2.3
+ '@lezer/highlight': 1.2.1
+ '@lezer/lr': 1.4.2
+
+ '@lezer/lr@1.4.2':
+ dependencies:
+ '@lezer/common': 1.2.3
+
'@lmdb/lmdb-darwin-arm64@2.5.3':
optional: true
@@ -17464,6 +17718,8 @@ snapshots:
'@lukeed/ms@2.0.2': {}
+ '@marijn/find-cluster-break@1.0.2': {}
+
'@mdx-js/util@2.0.0-next.8': {}
'@microsoft/fetch-event-source@2.0.1': {}
@@ -18028,6 +18284,16 @@ snapshots:
optionalDependencies:
'@types/react': 17.0.83
+ '@react-hook/intersection-observer@3.1.2(react@17.0.2)':
+ dependencies:
+ '@react-hook/passive-layout-effect': 1.2.1(react@17.0.2)
+ intersection-observer: 0.10.0
+ react: 17.0.2
+
+ '@react-hook/passive-layout-effect@1.2.1(react@17.0.2)':
+ dependencies:
+ react: 17.0.2
+
'@redux-devtools/extension@3.3.0(redux@4.2.1)':
dependencies:
'@babel/runtime': 7.23.9
@@ -18765,6 +19031,8 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
+ '@stitches/core@1.2.8': {}
+
'@stripe/react-stripe-js@1.16.5(@stripe/stripe-js@1.54.2)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)':
dependencies:
'@stripe/stripe-js': 1.54.2
@@ -21241,6 +21509,8 @@ snapshots:
classnames@2.3.2: {}
+ clean-set@1.1.2: {}
+
clean-stack@2.2.0: {}
cli-boxes@2.2.1: {}
@@ -21304,6 +21574,8 @@ snapshots:
codemirror@5.65.16: {}
+ codesandbox-import-util-types@2.2.3: {}
+
collapse-white-space@1.0.6: {}
collection-visit@1.0.0:
@@ -21581,6 +21853,8 @@ snapshots:
create-require@1.1.1: {}
+ crelt@1.0.6: {}
+
cross-fetch@3.1.4:
dependencies:
node-fetch: 2.6.1
@@ -22564,6 +22838,8 @@ snapshots:
escalade@3.1.2: {}
+ escape-carriage@1.3.1: {}
+
escape-goat@2.1.1: {}
escape-html@1.0.3: {}
@@ -24948,6 +25224,8 @@ snapshots:
interpret@2.2.0: {}
+ intersection-observer@0.10.0: {}
+
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@@ -27246,6 +27524,8 @@ snapshots:
os-tmpdir@1.0.2: {}
+ outvariant@1.4.0: {}
+
outvariant@1.4.3: {}
own-keys@1.0.1:
@@ -28233,6 +28513,10 @@ snapshots:
- supports-color
- vue-template-compiler
+ react-devtools-inline@4.4.0:
+ dependencies:
+ es6-symbol: 3.1.3
+
react-dom@17.0.2(react@17.0.2):
dependencies:
loose-envify: 1.4.0
@@ -29509,6 +29793,13 @@ snapshots:
state-toggle@1.0.3: {}
+ static-browser-server@1.0.3:
+ dependencies:
+ '@open-draft/deferred-promise': 2.2.0
+ dotenv: 16.4.5
+ mime-db: 1.54.0
+ outvariant: 1.4.3
+
static-extend@0.1.2:
dependencies:
define-property: 0.2.5
@@ -29566,6 +29857,8 @@ snapshots:
bare-events: 2.5.4
optional: true
+ strict-event-emitter@0.4.6: {}
+
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
@@ -29759,6 +30052,8 @@ snapshots:
schema-utils: 3.3.0
webpack: 5.90.3(webpack-cli@4.10.0)
+ style-mod@4.1.2: {}
+
style-to-object@0.3.0:
dependencies:
inline-style-parser: 0.1.1
@@ -31007,6 +31302,8 @@ snapshots:
dependencies:
browser-process-hrtime: 1.0.0
+ w3c-keyname@2.2.8: {}
+
w3c-xmlserializer@2.0.0:
dependencies:
xml-name-validator: 3.0.0
diff --git a/tools/challenge-parser/parser/__fixtures__/with-empty-interactive-element.md b/tools/challenge-parser/parser/__fixtures__/with-empty-interactive-element.md
new file mode 100644
index 00000000000..63addb4d117
--- /dev/null
+++ b/tools/challenge-parser/parser/__fixtures__/with-empty-interactive-element.md
@@ -0,0 +1,7 @@
+# --description--
+
+This is a test file for empty interactive elements.
+
+# --interactive--
+
+no subsections
diff --git a/tools/challenge-parser/parser/__fixtures__/with-interactive-non-code.md b/tools/challenge-parser/parser/__fixtures__/with-interactive-non-code.md
new file mode 100644
index 00000000000..ac045f95e12
--- /dev/null
+++ b/tools/challenge-parser/parser/__fixtures__/with-interactive-non-code.md
@@ -0,0 +1,17 @@
+# --interactive--
+
+Normal markdown
+
+```html
+This is NOT an interactive element
+```
+
+:::interactive_editor
+
+```js
+console.log('Interactive JS');
+```
+
+non-code md is not allowed
+
+:::
diff --git a/tools/challenge-parser/parser/__fixtures__/with-interactive.md b/tools/challenge-parser/parser/__fixtures__/with-interactive.md
new file mode 100644
index 00000000000..8778b44a7c9
--- /dev/null
+++ b/tools/challenge-parser/parser/__fixtures__/with-interactive.md
@@ -0,0 +1,43 @@
+# --interactive--
+
+Normal markdown
+
+```html
+This is NOT an interactive element
+```
+
+:::interactive_editor
+
+```js
+console.log('Interactive JS');
+```
+
+:::
+
+:::interactive_editor
+
+```html
+This is an interactive element
+```
+
+:::
+
+```html
+This is not interactive
+```
+
+:::interactive_editor
+
+```html
+This is an interactive element
+```
+
+```js
+console.log('Interactive JS');
+```
+
+:::
+
+```html
+This is also not interactive
+```
diff --git a/tools/challenge-parser/parser/__fixtures__/with-multiple-js-files.md b/tools/challenge-parser/parser/__fixtures__/with-multiple-js-files.md
new file mode 100644
index 00000000000..26892f8f6ef
--- /dev/null
+++ b/tools/challenge-parser/parser/__fixtures__/with-multiple-js-files.md
@@ -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');
+```
+
+:::
diff --git a/tools/challenge-parser/parser/__fixtures__/with-nested-instructions.md b/tools/challenge-parser/parser/__fixtures__/with-nested-instructions.md
new file mode 100644
index 00000000000..1303eb26ca9
--- /dev/null
+++ b/tools/challenge-parser/parser/__fixtures__/with-nested-instructions.md
@@ -0,0 +1,33 @@
+# --description--
+
+This is the main description.
+
+# --instructions--
+
+These are the main instructions at depth 1.
+
+```html
+Main instructions code
+```
+
+# --something-else--
+
+## --instructions--
+
+These are nested instructions at depth 2 that should be ignored.
+
+```html
+Nested instructions code
+```
+
+### --instructions--
+
+These are nested instructions at depth 3 that should also be ignored.
+
+# --hints--
+
+First hint
+
+```js
+// test code
+```
diff --git a/tools/challenge-parser/parser/index.js b/tools/challenge-parser/parser/index.js
index 231f15d38eb..a2e817b7c5c 100644
--- a/tools/challenge-parser/parser/index.js
+++ b/tools/challenge-parser/parser/index.js
@@ -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.
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.
diff --git a/tools/challenge-parser/parser/plugins/add-interactive-elements.js b/tools/challenge-parser/parser/plugins/add-interactive-elements.js
new file mode 100644
index 00000000000..d6940d69c8d
--- /dev/null
+++ b/tools/challenge-parser/parser/plugins/add-interactive-elements.js
@@ -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;
diff --git a/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js b/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js
new file mode 100644
index 00000000000..db6e9a185af
--- /dev/null
+++ b/tools/challenge-parser/parser/plugins/add-interactive-elements.test.js
@@ -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(
+ 'This is an interactive element
'
+ )
+ }
+ ],
+ 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.'
+ );
+ });
+});
diff --git a/tools/challenge-parser/parser/plugins/add-text.js b/tools/challenge-parser/parser/plugins/add-text.js
index d9bcc4c48f3..f593b928ba4 100644
--- a/tools/challenge-parser/parser/plugins/add-text.js
+++ b/tools/challenge-parser/parser/plugins/add-text.js
@@ -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,
diff --git a/tools/challenge-parser/parser/plugins/add-text.test.js b/tools/challenge-parser/parser/plugins/add-text.test.js
index d7f1f5bbbf3..66c5ae84017 100644
--- a/tools/challenge-parser/parser/plugins/add-text.test.js
+++ b/tools/challenge-parser/parser/plugins/add-text.test.js
@@ -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 = `
+These are the main instructions at depth 1.
+<div>Main instructions code</div>
+
+`;
+
+ expect(file.data[instructionsId]).toEqual(expectedText);
+ });
+
it('should have an output to match the snapshot', () => {
const plugin = addText([descriptionId, instructionsId]);
plugin(mockAST, file);
diff --git a/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js b/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js
index 927b9a0f5c2..86c2b9a3d10 100644
--- a/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js
+++ b/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js
@@ -97,3 +97,4 @@ function idToData(node, index, parent, seeds) {
}
module.exports.getFileVisitor = getFileVisitor;
+module.exports.getFilenames = getFilenames;
diff --git a/tools/challenge-parser/parser/plugins/utils/get-section.js b/tools/challenge-parser/parser/plugins/utils/get-section.js
index 326afbc5564..061b5b39449 100644
--- a/tools/challenge-parser/parser/plugins/utils/get-section.js
+++ b/tools/challenge-parser/parser/plugins/utils/get-section.js
@@ -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);
}
diff --git a/tools/challenge-parser/parser/plugins/utils/get-section.test.js b/tools/challenge-parser/parser/plugins/utils/get-section.test.js
index b4b2b286254..fad225f84fb 100644
--- a/tools/challenge-parser/parser/plugins/utils/get-section.test.js
+++ b/tools/challenge-parser/parser/plugins/utils/get-section.test.js
@@ -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();
diff --git a/tools/challenge-parser/parser/plugins/validate-sections.js b/tools/challenge-parser/parser/plugins/validate-sections.js
index dd4100a9aa8..9f7e10f0470 100644
--- a/tools/challenge-parser/parser/plugins/validate-sections.js
+++ b/tools/challenge-parser/parser/plugins/validate-sections.js
@@ -13,6 +13,7 @@ const VALID_MARKERS = [
'# --fillInTheBlank--',
'# --hints--',
'# --instructions--',
+ '# --interactive--',
'# --notes--',
'# --questions--',
'# --quizzes--',
diff --git a/tools/challenge-parser/parser/plugins/validate-sections.test.js b/tools/challenge-parser/parser/plugins/validate-sections.test.js
index d317f3dde65..7dbd114321d 100644
--- a/tools/challenge-parser/parser/plugins/validate-sections.test.js
+++ b/tools/challenge-parser/parser/plugins/validate-sections.test.js
@@ -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--".'
);
});