feat(client): use typescript in challenges (#56253)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-10-28 21:40:11 +01:00
committed by GitHub
parent e40a60bc60
commit e9a4e92955
16 changed files with 483 additions and 237 deletions

View File

@@ -1,9 +1,12 @@
import { HandlerProps } from 'react-reflex';
import { SuperBlocks } from '../../../shared/config/curriculum';
import { BlockLayouts, BlockTypes } from '../../../shared/config/blocks';
import type { ChallengeFile, Ext } from '../../../shared/utils/polyvinyl';
import { Themes } from '../components/settings/theme';
import { type CertTitle } from '../../config/cert-and-project-map';
export type { ChallengeFile, Ext };
export type Steps = {
isHonest?: boolean;
currentCerts?: Array<CurrentCert>;
@@ -386,8 +389,12 @@ export type CompletedChallenge = {
examResults?: GeneratedExamResults;
};
export type Ext = 'js' | 'html' | 'css' | 'jsx';
export type FileKey = 'scriptjs' | 'indexhtml' | 'stylescss' | 'indexjsx';
export type FileKey =
| 'scriptjs'
| 'indexts'
| 'indexhtml'
| 'stylescss'
| 'indexjsx';
export type ChallengeMeta = {
block: string;
@@ -422,21 +429,6 @@ export type FileKeyChallenge = {
tail: string;
};
export type ChallengeFile = {
fileKey: string;
ext: Ext;
name: string;
editableRegionBoundaries?: number[];
usesMultifileEditor?: boolean;
error?: unknown;
head: string;
tail: string;
seed: string;
contents: string;
id: string;
history: string[];
};
export type ChallengeFiles = ChallengeFile[] | null;
export interface UserFetchState {

View File

@@ -9,7 +9,7 @@ import {
stubTrue
} from 'lodash-es';
import sassData from '../../../../../client/config/browser-scripts/sass-compile.json';
import sassData from '../../../../config/browser-scripts/sass-compile.json';
import {
transformContents,
transformHeadTailAndContents,
@@ -18,6 +18,10 @@ import {
createSource
} from '../../../../../shared/utils/polyvinyl';
import { WorkerExecutor } from '../utils/worker-executor';
import {
compileTypeScriptCode,
initTypeScriptService
} from '../utils/typescript-worker-handler';
const { filename: sassCompile } = sassData;
@@ -97,12 +101,13 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
const testJS = matchesProperty('ext', 'js');
const testJSX = matchesProperty('ext', 'jsx');
const testTypeScript = matchesProperty('ext', 'ts');
const testHTML = matchesProperty('ext', 'html');
const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX);
const testHTML$JS$JSX$TS = overSome(testHTML, testJS, testJSX, testTypeScript);
const replaceNBSP = cond([
[
testHTML$JS$JSX,
testHTML$JS$JSX$TS,
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
],
[stubTrue, identity]
@@ -112,19 +117,19 @@ const babelTransformer = loopProtectOptions => {
return cond([
[
testJS,
async code => {
async challengeFile => {
await loadBabel();
await loadPresetEnv();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return transformHeadTailAndContents(
babelTransformCode(babelOptions),
code
challengeFile
);
}
],
[
testJSX,
async code => {
async challengeFile => {
await loadBabel();
await loadPresetReact();
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
@@ -134,7 +139,22 @@ const babelTransformer = loopProtectOptions => {
babelTransformCode(babelOptions)
),
partial(setExt, 'js')
)(code);
)(challengeFile);
}
],
[
testTypeScript,
async challengeFile => {
await loadBabel();
await initTypeScriptService();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return flow(
partial(transformHeadTailAndContents, compileTypeScriptCode),
partial(
transformHeadTailAndContents,
babelTransformCode(babelOptions)
)
)(challengeFile);
}
],
[stubTrue, identity]
@@ -197,7 +217,7 @@ async function transformScript(documentElement) {
// This does the final transformations of the files needed to embed them into
// HTML.
export const embedFilesInHtml = async function (challengeFiles) {
const { indexHtml, stylesCss, scriptJs, indexJsx } =
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs } =
challengeFilesToObject(challengeFiles);
const embedStylesAndScript = (documentElement, contentDocument) => {
@@ -207,6 +227,10 @@ export const embedFilesInHtml = async function (challengeFiles) {
const script =
documentElement.querySelector('script[src="script.js"]') ??
documentElement.querySelector('script[src="./script.js"]');
const tsScript =
documentElement.querySelector('script[src="index.ts"]') ??
documentElement.querySelector('script[src="./index.ts"]');
if (link) {
const style = contentDocument.createElement('style');
style.classList.add('fcc-injected-styles');
@@ -222,6 +246,11 @@ export const embedFilesInHtml = async function (challengeFiles) {
script.removeAttribute('src');
script.setAttribute('data-src', 'script.js');
}
if (tsScript) {
tsScript.innerHTML = indexTs?.contents;
tsScript.removeAttribute('src');
tsScript.setAttribute('data-src', 'index.ts');
}
return documentElement.innerHTML;
};
@@ -235,8 +264,10 @@ export const embedFilesInHtml = async function (challengeFiles) {
return [challengeFiles, `<script>${indexJsx.contents}</script>`];
} else if (scriptJs) {
return [challengeFiles, `<script>${scriptJs.contents}</script>`];
} else if (indexTs) {
return [challengeFiles, `<script>${indexTs.contents}</script>`];
} else {
throw Error('No html or js(x) file found');
throw Error('No html, ts or js(x) file found');
}
};
@@ -247,7 +278,8 @@ function challengeFilesToObject(challengeFiles) {
);
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
return { indexHtml, indexJsx, stylesCss, scriptJs };
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs };
}
const parseAndTransform = async function (transform, contents) {

View File

@@ -3,10 +3,7 @@ import frameRunnerData from '../../../../../client/config/browser-scripts/frame-
import jsTestEvaluatorData from '../../../../../client/config/browser-scripts/test-evaluator.json';
import pyTestEvaluatorData from '../../../../../client/config/browser-scripts/python-test-evaluator.json';
import {
ChallengeFile as PropTypesChallengeFile,
ChallengeMeta
} from '../../../redux/prop-types';
import { ChallengeFile, ChallengeMeta } from '../../../redux/prop-types';
import { concatHtml } from '../rechallenge/builders';
import {
getTransformers,
@@ -25,12 +22,6 @@ import {
} from './frame';
import { WorkerExecutor } from './worker-executor';
interface ChallengeFile extends PropTypesChallengeFile {
source: string;
index: string;
editableContents: string;
}
interface BuildChallengeData extends Context {
challengeType: number;
challengeFiles?: ChallengeFile[];

View File

@@ -13,7 +13,7 @@ export interface Source {
index: string;
contents?: string;
editableContents: string;
original: { [key: string]: string };
original: { [key: string]: string | null };
}
export interface Context {

View File

@@ -0,0 +1,44 @@
import typeScriptWorkerData from '../../../../config/browser-scripts/typescript-worker.json';
import { awaitResponse } from './worker-messenger';
const typeScriptWorkerSrc = `/js/${typeScriptWorkerData.filename}.js`;
let worker: Worker | null = null;
function getTypeScriptWorker(): Worker {
if (!worker) {
worker = new Worker(typeScriptWorkerSrc);
}
return worker;
}
export function compileTypeScriptCode(code: string): Promise<string> {
return awaitResponse({
worker: getTypeScriptWorker(),
message: { type: 'compile', code },
onMessage: (data, onSuccess, onFailure) => {
if (data.type === 'compiled') {
if (!data.error) {
onSuccess(data.value);
} else {
onFailure(Error(data.error));
}
} else {
onFailure(Error('unable to compile code'));
}
}
});
}
export function initTypeScriptService(): Promise<boolean> {
return awaitResponse({
worker: getTypeScriptWorker(),
message: { type: 'init' },
onMessage: (data, onSuccess) => {
if (data.type === 'ready') {
onSuccess(true);
}
// otherwise it times out.
}
});
}

View File

@@ -17,7 +17,7 @@
*/
export function awaitResponse<
MessageOut,
MessageIn extends { type: string; value: Value },
MessageIn extends { type: string; value: Value; error: string },
Value
>({
worker,
@@ -28,25 +28,27 @@ export function awaitResponse<
message: MessageOut;
onMessage: (
response: MessageIn,
resolve: (res: Value) => void,
reject: (err: string) => void
onSuccess: (res: Value) => void,
onFailure: (err: Error) => void
) => void;
}): Promise<Value> {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
// TODO: Figure out how to ensure the worker is ready and/or handle when it
// is not.
const id = setTimeout(() => {
channel.port1.close();
reject(Error('No response from worker'));
}, 5000);
return new Promise(
(resolve: (res: Value) => void, reject: (err: Error) => void) => {
const channel = new MessageChannel();
// TODO: Figure out how to ensure the worker is ready and/or handle when it
// is not.
const id = setTimeout(() => {
channel.port1.close();
reject(Error('No response from worker'));
}, 5000);
channel.port1.onmessage = (event: MessageEvent<MessageIn>) => {
clearTimeout(id);
channel.port1.close();
onMessage(event.data, resolve, reject);
};
channel.port1.onmessage = (event: MessageEvent<MessageIn>) => {
clearTimeout(id);
channel.port1.close();
onMessage(event.data, resolve, reject);
};
worker.postMessage(message, [channel.port2]);
});
worker.postMessage(message, [channel.port2]);
}
);
}