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--".' ); });