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
+6
View File
@@ -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);
};
+2
View File
@@ -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",
+13
View File
@@ -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;
@@ -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;
}
@@ -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 (
<div className='interactive-editor-wrapper'>
<Sandpack
template={
got('tsx')
? 'react-ts'
: got('jsx')
? 'react'
: got('ts')
? 'vanilla-ts'
: got('html')
? 'static'
: 'vanilla'
}
files={spFiles}
theme={{
colors: {
surface1: '#0a0a23',
surface2: '#3b3b4f',
surface3: '#2a2a40',
hover: '#3b3b4f'
},
syntax: freeCodeCampDarkSyntax
}}
options={{
editorHeight: 450,
editorWidthPercentage: 60,
showConsole: showConsole,
showConsoleButton: showConsole,
layout: got('html') ? 'preview' : 'console',
showLineNumbers: true
}}
/>
</div>
);
};
InteractiveEditor.displayName = 'InteractiveEditor';
export default InteractiveEditor;
@@ -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 <PrismFormatted text={nodule.data} />;
case 'interactiveEditor':
return <InteractiveEditor files={nodule.data} />;
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 = ({
</Col>
)}
{nodules?.map((nodule, i) => {
return (
<React.Fragment key={i}>{renderNodule(nodule)}</React.Fragment>
);
})}
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
{videoId && (
<>
@@ -332,6 +352,10 @@ export const query = graphql`
blockType
challengeType
description
nodules {
type
data
}
explanation
helpCategory
instructions
@@ -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
<html>
<head>
</head>
<body>
<canvas id="my-canvas" width="400" height="400"></canvas>
<script src="index.js"></script>
</body>
</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
<html>
<head>
</head>
<body>
<canvas id="my-text-canvas" width="300" height="70"></canvas>
<script src="index.js"></script>
</body>
</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.
+17
View File
@@ -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',
+297
View File
@@ -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
@@ -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--".'
);
});