From 3b23d32396b575c8db11d7f1195038d2b40bdab4 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 27 Nov 2024 08:58:22 +0100 Subject: [PATCH] feat(client): import user's react file into html (#57184) Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> --- .../Challenges/classic/multifile-editor.tsx | 2 +- .../Challenges/rechallenge/transformers.js | 87 +++++++++++++++---- .../src/templates/Challenges/utils/build.ts | 16 +++- 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/client/src/templates/Challenges/classic/multifile-editor.tsx b/client/src/templates/Challenges/classic/multifile-editor.tsx index b0046260ffc..86c60e044d6 100644 --- a/client/src/templates/Challenges/classic/multifile-editor.tsx +++ b/client/src/templates/Challenges/classic/multifile-editor.tsx @@ -106,10 +106,10 @@ const MultifileEditor = (props: MultifileEditorProps) => { const editorKeys = []; - if (indexjsx) editorKeys.push('indexjsx'); if (indexhtml) editorKeys.push('indexhtml'); if (stylescss) editorKeys.push('stylescss'); if (scriptjs) editorKeys.push('scriptjs'); + if (indexjsx) editorKeys.push('indexjsx'); if (mainpy) editorKeys.push('mainpy'); if (indexts) editorKeys.push('indexts'); diff --git a/client/src/templates/Challenges/rechallenge/transformers.js b/client/src/templates/Challenges/rechallenge/transformers.js index 2782c98bdf2..b01c3a8a31b 100644 --- a/client/src/templates/Challenges/rechallenge/transformers.js +++ b/client/src/templates/Challenges/rechallenge/transformers.js @@ -28,6 +28,7 @@ const protectTimeout = 100; const testProtectTimeout = 1500; const loopsPerTimeoutCheck = 100; const testLoopsPerTimeoutCheck = 2000; +const MODULE_TRANSFORM_PLUGIN = 'transform-modules-umd'; function loopProtectCB(line) { console.log( @@ -132,6 +133,18 @@ const getJSXTranspiler = loopProtectOptions => async challengeFile => { ); }; +const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => { + await loadBabel(); + await loadPresetReact(); + const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions); + const babelOptions = { + ...baseOptions, + plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN], + moduleId: 'index' // TODO: this should be dynamic + }; + return transformContents(babelTransformCode(babelOptions), challengeFile); +}; + const getTSTranspiler = loopProtectOptions => async challengeFile => { await loadBabel(); await checkTSServiceIsReady(); @@ -147,7 +160,15 @@ const createTranspiler = loopProtectOptions => { [testJS, getJSTranspiler(loopProtectOptions)], [testJSX, getJSXTranspiler(loopProtectOptions)], [testTypeScript, getTSTranspiler(loopProtectOptions)], - [testHTML, transformHtml], + [testHTML, getHtmlTranspiler({ useModules: false })], + [stubTrue, identity] + ]); +}; + +const createModuleTransformer = loopProtectOptions => { + return cond([ + [testJSX, getJSXModuleTranspiler(loopProtectOptions)], + [testHTML, getHtmlTranspiler({ useModules: true })], [stubTrue, identity] ]); }; @@ -186,7 +207,7 @@ async function transformSASS(documentElement) { ); } -async function transformScript(documentElement) { +async function transformScript(documentElement, { useModules }) { await loadBabel(); await loadPresetEnv(); await loadPresetReact(); @@ -196,12 +217,19 @@ async function transformScript(documentElement) { // TODO: make the use of JSX conditional on more than just the script type. // It should only be used for React challenges since it would be confusing // for learners to see the results of a transformation they didn't ask for. - const options = isBabel ? presetsJSX : presetsJS; + const baseOptions = isBabel ? presetsJSX : presetsJS; - if (isBabel) script.removeAttribute('type'); // otherwise the browser will ignore the script - script.innerHTML = babelTransformCode(getBabelOptions(options))( - script.innerHTML - ); + const options = { + ...baseOptions, + ...(useModules && { plugins: [MODULE_TRANSFORM_PLUGIN] }) + }; + + // The type has to be removed, otherwise the browser will ignore the script. + // However, if we're importing modules, the type will be removed when the + // scripts are embedded in the HTML. + if (isBabel && !useModules) script.removeAttribute('type'); + + script.innerHTML = babelTransformCode(options)(script.innerHTML); }); } @@ -222,6 +250,15 @@ export const embedFilesInHtml = async function (challengeFiles) { const tsScript = documentElement.querySelector('script[src="index.ts"]') ?? documentElement.querySelector('script[src="./index.ts"]'); + + const jsxScript = + documentElement.querySelector( + `script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.jsx"]` + ) ?? + documentElement.querySelector( + `script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.jsx"]` + ); + if (link) { const style = contentDocument.createElement('style'); style.classList.add('fcc-injected-styles'); @@ -242,6 +279,13 @@ export const embedFilesInHtml = async function (challengeFiles) { tsScript.removeAttribute('src'); tsScript.setAttribute('data-src', 'index.ts'); } + if (jsxScript) { + jsxScript.innerHTML = indexJsx?.contents; + jsxScript.removeAttribute('src'); + jsxScript.removeAttribute('type'); + jsxScript.setAttribute('data-src', 'index.jsx'); + jsxScript.setAttribute('data-type', 'text/babel'); + } return documentElement.innerHTML; }; @@ -278,18 +322,19 @@ const parseAndTransform = async function (transform, contents) { return await transform(newDoc.documentElement, newDoc); }; -const transformHtml = async function (file) { - const transform = async documentElement => { - await Promise.all([ - transformSASS(documentElement), - transformScript(documentElement) - ]); - return documentElement.innerHTML; - }; +const getHtmlTranspiler = scriptOptions => + async function (file) { + const transform = async documentElement => { + await Promise.all([ + transformSASS(documentElement), + transformScript(documentElement, scriptOptions) + ]); + return documentElement.innerHTML; + }; - const contents = await parseAndTransform(transform, file.contents); - return transformContents(() => contents, file); -}; + const contents = await parseAndTransform(transform, file.contents); + return transformContents(() => contents, file); + }; export const getTransformers = loopProtectOptions => [ createSource, @@ -298,6 +343,12 @@ export const getTransformers = loopProtectOptions => [ partial(compileHeadTail, '') ]; +export const getMultifileJSXTransformers = loopProtectOptions => [ + createSource, + replaceNBSP, + createModuleTransformer(loopProtectOptions) +]; + export const getPythonTransformers = () => [ createSource, replaceNBSP, diff --git a/client/src/templates/Challenges/utils/build.ts b/client/src/templates/Challenges/utils/build.ts index dc1954cf1ef..c39e703bef9 100644 --- a/client/src/templates/Challenges/utils/build.ts +++ b/client/src/templates/Challenges/utils/build.ts @@ -8,7 +8,8 @@ import { concatHtml } from '../rechallenge/builders'; import { getTransformers, embedFilesInHtml, - getPythonTransformers + getPythonTransformers, + getMultifileJSXTransformers } from '../rechallenge/transformers'; import { createTestFramer, @@ -227,11 +228,18 @@ export async function buildDOMChallenge( ): Promise { // TODO: make this required in the schema. if (!challengeFiles) throw Error('No challenge files provided'); - const loadEnzyme = challengeFiles.some( + const hasJsx = challengeFiles.some( challengeFile => challengeFile.ext === 'jsx' ); + const isMultifile = challengeFiles.length > 1; - const pipeLine = composeFunctions(...getTransformers(options)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const transformers = + isMultifile && hasJsx + ? getMultifileJSXTransformers(options) + : getTransformers(options); + + const pipeLine = composeFunctions(...transformers); const usesTestRunner = options?.usesTestRunner ?? false; const finalFiles = await Promise.all(challengeFiles.map(pipeLine)); const error = finalFiles.find(({ error }) => error)?.error; @@ -257,7 +265,7 @@ export async function buildDOMChallenge( challengeType: challengeTypes.html, build: concatHtml(toBuild), sources: buildSourceMap(embeddedFiles), - loadEnzyme, + loadEnzyme: hasJsx, error }; }