mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-04 17:01:16 -05:00
feat(client): use typescript in challenges (#56253)
Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
e40a60bc60
commit
e9a4e92955
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user