mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-19 13:00:32 -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
7
.gitignore
vendored
7
.gitignore
vendored
@@ -157,12 +157,7 @@ shared/config/curriculum.json
|
||||
shared/config/*.js
|
||||
|
||||
### Generated utils files ###
|
||||
shared/utils/get-lines.js
|
||||
shared/utils/get-lines.test.js
|
||||
shared/utils/validate.js
|
||||
shared/utils/validate.test.js
|
||||
shared/utils/is-audited.js
|
||||
shared/utils/shuffle-array.js
|
||||
shared/utils/*.js
|
||||
|
||||
### Old Generated files ###
|
||||
# These files are no longer generated by the client, but can
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -885,6 +885,9 @@ importers:
|
||||
|
||||
shared:
|
||||
dependencies:
|
||||
'@types/invariant':
|
||||
specifier: ^2.2.37
|
||||
version: 2.2.37
|
||||
invariant:
|
||||
specifier: 2.2.4
|
||||
version: 2.2.4
|
||||
@@ -1126,6 +1129,9 @@ importers:
|
||||
'@types/lodash-es':
|
||||
specifier: 4.17.12
|
||||
version: 4.17.12
|
||||
'@typescript/vfs':
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(typescript@5.4.5)
|
||||
babel-loader:
|
||||
specifier: 8.3.0
|
||||
version: 8.3.0(@babel/core@7.23.7)(webpack@5.90.3)
|
||||
@@ -4181,6 +4187,9 @@ packages:
|
||||
'@types/inquirer@8.2.10':
|
||||
resolution: {integrity: sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA==}
|
||||
|
||||
'@types/invariant@2.2.37':
|
||||
resolution: {integrity: sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.4':
|
||||
resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==}
|
||||
|
||||
@@ -4596,6 +4605,11 @@ packages:
|
||||
resolution: {integrity: sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==}
|
||||
engines: {node: ^16.0.0 || >=18.0.0}
|
||||
|
||||
'@typescript/vfs@1.6.0':
|
||||
resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
|
||||
'@uiw/react-codemirror@3.2.10':
|
||||
resolution: {integrity: sha512-sSabPpOQFFRAZwm/JZ6gRRqQg0PBPw9nRAT9YepqUSM3TJHQzavZDRDxKF8B9jL+KU24+aKqM/clLlpPPPF5sQ==}
|
||||
peerDependencies:
|
||||
@@ -18389,6 +18403,8 @@ snapshots:
|
||||
'@types/through': 0.0.33
|
||||
rxjs: 7.8.1
|
||||
|
||||
'@types/invariant@2.2.37': {}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.4': {}
|
||||
|
||||
'@types/istanbul-lib-report@3.0.1':
|
||||
@@ -18907,6 +18923,13 @@ snapshots:
|
||||
'@typescript-eslint/types': 7.1.1
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@typescript/vfs@1.6.0(typescript@5.4.5)':
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
typescript: 5.4.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@uiw/react-codemirror@3.2.10(@babel/runtime@7.23.9)(codemirror@5.65.16)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
|
||||
"dependencies": {
|
||||
"@types/invariant": "^2.2.37",
|
||||
"invariant": "2.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
// originally based off of https://github.com/gulpjs/vinyl
|
||||
const invariant = require('invariant');
|
||||
|
||||
// interface PolyVinyl {
|
||||
// source: String,
|
||||
// contents: String,
|
||||
// name: String,
|
||||
// ext: String,
|
||||
// path: String,
|
||||
// key: String,
|
||||
// head: String,
|
||||
// tail: String,
|
||||
// history: [...String],
|
||||
// error: Null|Object|Error
|
||||
// }
|
||||
|
||||
// createPoly({
|
||||
// name: String,
|
||||
// ext: String,
|
||||
// contents: String,
|
||||
// history?: [...String],
|
||||
// }) => PolyVinyl, throws
|
||||
function createPoly({ name, ext, contents, history, ...rest }) {
|
||||
invariant(typeof name === 'string', 'name must be a string but got %s', name);
|
||||
|
||||
invariant(typeof ext === 'string', 'ext must be a string, but was %s', ext);
|
||||
|
||||
invariant(
|
||||
typeof contents === 'string',
|
||||
'contents must be a string but got %s',
|
||||
contents
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
history: Array.isArray(history) ? history : [name + '.' + ext],
|
||||
name,
|
||||
ext,
|
||||
path: name + '.' + ext,
|
||||
fileKey: name + ext,
|
||||
contents,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
// isPoly(poly: Any) => Boolean
|
||||
function isPoly(poly) {
|
||||
return (
|
||||
poly &&
|
||||
typeof poly.contents === 'string' &&
|
||||
typeof poly.name === 'string' &&
|
||||
typeof poly.ext === 'string' &&
|
||||
Array.isArray(poly.history)
|
||||
);
|
||||
}
|
||||
|
||||
// checkPoly(poly: Any) => Void, throws
|
||||
function checkPoly(poly) {
|
||||
invariant(
|
||||
isPoly(poly),
|
||||
'function should receive a PolyVinyl, but got %s',
|
||||
poly
|
||||
);
|
||||
}
|
||||
|
||||
// setContent(contents: String, poly: PolyVinyl) => PolyVinyl
|
||||
// setContent will loose source if set
|
||||
function setContent(contents, poly) {
|
||||
checkPoly(poly);
|
||||
return {
|
||||
...poly,
|
||||
contents,
|
||||
source: null
|
||||
};
|
||||
}
|
||||
|
||||
// setExt(ext: String, poly: PolyVinyl) => PolyVinyl
|
||||
function setExt(ext, poly) {
|
||||
checkPoly(poly);
|
||||
const newPoly = {
|
||||
...poly,
|
||||
ext,
|
||||
path: poly.name + '.' + ext,
|
||||
fileKey: poly.name + ext
|
||||
};
|
||||
newPoly.history = [...poly.history, newPoly.path];
|
||||
return newPoly;
|
||||
}
|
||||
|
||||
// This is currently only used to add back properties that are not stored in the
|
||||
// database.
|
||||
function regeneratePathAndHistory(poly) {
|
||||
const newPath = poly.name + '.' + poly.ext;
|
||||
const newPoly = {
|
||||
...poly,
|
||||
path: newPath,
|
||||
history: [newPath]
|
||||
};
|
||||
checkPoly(newPoly);
|
||||
return newPoly;
|
||||
}
|
||||
|
||||
// clearHeadTail(poly: PolyVinyl) => PolyVinyl
|
||||
function clearHeadTail(poly) {
|
||||
checkPoly(poly);
|
||||
return {
|
||||
...poly,
|
||||
head: '',
|
||||
tail: ''
|
||||
};
|
||||
}
|
||||
|
||||
// compileHeadTail(padding: String, poly: PolyVinyl) => PolyVinyl
|
||||
function compileHeadTail(padding = '', poly) {
|
||||
return clearHeadTail(
|
||||
transformContents(
|
||||
() => [poly.head, poly.contents, poly.tail].join(padding),
|
||||
poly
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// transformContents(
|
||||
// wrap: (contents: String) => String,
|
||||
// poly: PolyVinyl
|
||||
// ) => PolyVinyl
|
||||
// transformContents will keep a copy of the original
|
||||
// code in the `source` property. If the original polyvinyl
|
||||
// already contains a source, this version will continue as
|
||||
// the source property
|
||||
function transformContents(wrap, poly) {
|
||||
const newPoly = setContent(wrap(poly.contents), poly);
|
||||
// if no source exist, set the original contents as source
|
||||
newPoly.source = poly.source || poly.contents;
|
||||
return newPoly;
|
||||
}
|
||||
|
||||
// transformHeadTailAndContents(
|
||||
// wrap: (source: String) => String,
|
||||
// poly: PolyVinyl
|
||||
// ) => PolyVinyl
|
||||
function transformHeadTailAndContents(wrap, poly) {
|
||||
return {
|
||||
...transformContents(wrap, poly),
|
||||
head: wrap(poly.head),
|
||||
tail: wrap(poly.tail)
|
||||
};
|
||||
}
|
||||
|
||||
// createSource(poly: PolyVinyl) => PolyVinyl
|
||||
function createSource(poly) {
|
||||
return {
|
||||
...poly,
|
||||
source: poly.source || poly.contents
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPoly,
|
||||
isPoly,
|
||||
setContent,
|
||||
setExt,
|
||||
createSource,
|
||||
compileHeadTail,
|
||||
regeneratePathAndHistory,
|
||||
transformContents,
|
||||
transformHeadTailAndContents
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import polyvinyl from './polyvinyl';
|
||||
import { createPoly, createSource } from './polyvinyl';
|
||||
|
||||
const polyData = {
|
||||
name: 'test',
|
||||
@@ -9,21 +9,21 @@ const polyData = {
|
||||
|
||||
describe('createSource', () => {
|
||||
it('should return a vinyl object with a source matching the contents', () => {
|
||||
const original = polyvinyl.createPoly(polyData);
|
||||
const original = createPoly(polyData);
|
||||
|
||||
const updated = polyvinyl.createSource(original);
|
||||
const updated = createSource(original);
|
||||
expect(original).not.toHaveProperty('source');
|
||||
expect(updated).toHaveProperty('source', 'var hello = world;');
|
||||
expect(updated).toMatchObject(original);
|
||||
});
|
||||
|
||||
it('should not update the source if it already exists', () => {
|
||||
const original = polyvinyl.createPoly({
|
||||
const original = createPoly({
|
||||
...polyData,
|
||||
source: 'const hello = world;'
|
||||
});
|
||||
|
||||
const updated = polyvinyl.createSource(original);
|
||||
const updated = createSource(original);
|
||||
expect(updated).toStrictEqual(original);
|
||||
});
|
||||
});
|
||||
185
shared/utils/polyvinyl.ts
Normal file
185
shared/utils/polyvinyl.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// originally based off of https://github.com/gulpjs/vinyl
|
||||
import invariant from 'invariant';
|
||||
|
||||
export type Ext = 'js' | 'html' | 'css' | 'jsx' | 'ts';
|
||||
|
||||
export type ChallengeFile = {
|
||||
fileKey: string;
|
||||
ext: Ext;
|
||||
name: string;
|
||||
editableRegionBoundaries?: number[];
|
||||
editableContents?: string;
|
||||
usesMultifileEditor?: boolean;
|
||||
error?: unknown;
|
||||
head: string;
|
||||
tail: string;
|
||||
seed: string;
|
||||
contents: string;
|
||||
source?: string | null;
|
||||
id: string;
|
||||
history: string[];
|
||||
};
|
||||
|
||||
type PolyProps = {
|
||||
name: string;
|
||||
ext: string;
|
||||
contents: string;
|
||||
history?: string[];
|
||||
};
|
||||
|
||||
// The types are a little awkward, but should suffice until we move the
|
||||
// curriculum to TypeScript.
|
||||
type AddedProperties = {
|
||||
path: string;
|
||||
fileKey: string;
|
||||
error: null;
|
||||
};
|
||||
|
||||
export function createPoly<Rest>({
|
||||
name,
|
||||
ext,
|
||||
contents,
|
||||
history,
|
||||
...rest
|
||||
}: PolyProps & Rest): PolyProps & AddedProperties & Rest {
|
||||
invariant(typeof name === 'string', 'name must be a string but got %s', name);
|
||||
|
||||
invariant(typeof ext === 'string', 'ext must be a string, but was %s', ext);
|
||||
|
||||
invariant(
|
||||
typeof contents === 'string',
|
||||
'contents must be a string but got %s',
|
||||
contents
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
history: Array.isArray(history) ? history : [name + '.' + ext],
|
||||
name,
|
||||
ext,
|
||||
path: name + '.' + ext,
|
||||
fileKey: name + ext,
|
||||
contents,
|
||||
error: null
|
||||
} as PolyProps & AddedProperties & Rest;
|
||||
}
|
||||
|
||||
export function isPoly(poly: unknown): poly is ChallengeFile {
|
||||
return (
|
||||
!!poly &&
|
||||
typeof poly === 'object' &&
|
||||
'contents' in poly &&
|
||||
typeof poly.contents === 'string' &&
|
||||
'name' in poly &&
|
||||
typeof poly.name === 'string' &&
|
||||
'ext' in poly &&
|
||||
typeof poly.ext === 'string' &&
|
||||
'history' in poly &&
|
||||
Array.isArray(poly.history)
|
||||
);
|
||||
}
|
||||
|
||||
function checkPoly(poly: ChallengeFile) {
|
||||
invariant(
|
||||
isPoly(poly),
|
||||
'function should receive a PolyVinyl, but got %s',
|
||||
poly
|
||||
);
|
||||
}
|
||||
|
||||
// setContent will lose source if set
|
||||
export function setContent(
|
||||
contents: string,
|
||||
poly: ChallengeFile
|
||||
): ChallengeFile {
|
||||
checkPoly(poly);
|
||||
return {
|
||||
...poly,
|
||||
contents,
|
||||
source: null
|
||||
};
|
||||
}
|
||||
|
||||
export async function setExt(ext: string, polyP: Promise<ChallengeFile>) {
|
||||
const poly = await polyP;
|
||||
checkPoly(poly);
|
||||
const newPoly = {
|
||||
...poly,
|
||||
ext,
|
||||
path: poly.name + '.' + ext,
|
||||
fileKey: poly.name + ext
|
||||
};
|
||||
newPoly.history = [...poly.history, newPoly.path];
|
||||
return newPoly;
|
||||
}
|
||||
|
||||
// This is currently only used to add back properties that are not stored in the
|
||||
// database.
|
||||
export function regeneratePathAndHistory(poly: ChallengeFile) {
|
||||
const newPath = poly.name + '.' + poly.ext;
|
||||
const newPoly = {
|
||||
...poly,
|
||||
path: newPath,
|
||||
history: [newPath]
|
||||
};
|
||||
checkPoly(newPoly);
|
||||
return newPoly;
|
||||
}
|
||||
|
||||
async function clearHeadTail(polyP: Promise<ChallengeFile>) {
|
||||
const poly = await polyP;
|
||||
checkPoly(poly);
|
||||
return {
|
||||
...poly,
|
||||
head: '',
|
||||
tail: ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function compileHeadTail(padding = '', poly: ChallengeFile) {
|
||||
return clearHeadTail(
|
||||
transformContents(
|
||||
() => [poly.head, poly.contents, poly.tail].join(padding),
|
||||
poly
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type Wrapper = (x: string) => Promise<string> | string;
|
||||
// transformContents will keep a copy of the original
|
||||
// code in the `source` property. If the original polyvinyl
|
||||
// already contains a source, this version will continue as
|
||||
// the source property
|
||||
export async function transformContents(
|
||||
wrap: Wrapper,
|
||||
polyP: ChallengeFile | Promise<ChallengeFile>
|
||||
) {
|
||||
const poly = await polyP;
|
||||
const newPoly = setContent(await wrap(poly.contents), poly);
|
||||
// if no source exist, set the original contents as source
|
||||
newPoly.source = poly.source || poly.contents;
|
||||
return newPoly;
|
||||
}
|
||||
|
||||
export async function transformHeadTailAndContents(
|
||||
wrap: Wrapper,
|
||||
polyP: ChallengeFile | Promise<ChallengeFile>
|
||||
) {
|
||||
const poly = await polyP;
|
||||
const contents = await transformContents(wrap, poly);
|
||||
const head = await wrap(poly.head);
|
||||
const tail = await wrap(poly.tail);
|
||||
return {
|
||||
...contents,
|
||||
head,
|
||||
tail
|
||||
};
|
||||
}
|
||||
|
||||
// createSource(poly: PolyVinyl) => PolyVinyl
|
||||
export function createSource(poly: Pick<ChallengeFile, 'contents' | 'source'>) {
|
||||
return {
|
||||
...poly,
|
||||
source: poly.source || poly.contents
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface InitTestFrameArg {
|
||||
code: {
|
||||
contents?: string;
|
||||
editableContents?: string;
|
||||
original?: { [id: string]: string };
|
||||
original?: { [id: string]: string | null };
|
||||
};
|
||||
getUserInput?: (fileName: string) => string;
|
||||
loadEnzyme?: () => void;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/enzyme-adapter-react-16": "1.0.9",
|
||||
"@types/jquery": "3.5.29",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@typescript/vfs": "^1.6.0",
|
||||
"babel-loader": "8.3.0",
|
||||
"chai": "4.4.1",
|
||||
"copy-webpack-plugin": "9.1.0",
|
||||
|
||||
147
tools/client-plugins/browser-scripts/typescript-worker.ts
Normal file
147
tools/client-plugins/browser-scripts/typescript-worker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { type VirtualTypeScriptEnvironment } from '@typescript/vfs';
|
||||
import type { CompilerOptions, CompilerHost } from 'typescript';
|
||||
|
||||
// Most of the ts types are only a guideline. This is because we're not bundling
|
||||
// TS in this worker. The specific TS version is going to be determined by the
|
||||
// challenge (in general - it will be hardcoded in the MVP). So, the vfs types
|
||||
// should be correct, but ts may not be.
|
||||
declare const tsvfs: typeof import('@typescript/vfs');
|
||||
declare const createDefaultMapFromCDN: typeof import('@typescript/vfs').createDefaultMapFromCDN;
|
||||
declare const createVirtualCompilerHost: typeof import('@typescript/vfs').createVirtualCompilerHost;
|
||||
declare const ts: typeof import('typescript');
|
||||
|
||||
const ctx: Worker & typeof globalThis = self as unknown as Worker &
|
||||
typeof globalThis;
|
||||
|
||||
let tsEnv: VirtualTypeScriptEnvironment | null = null;
|
||||
let compilerHost: CompilerHost | null = null;
|
||||
|
||||
interface TSCompileEvent extends MessageEvent {
|
||||
data: {
|
||||
type: 'compile';
|
||||
code: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TSCompiledMessage {
|
||||
type: 'compiled';
|
||||
value: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface InitRequestEvent extends MessageEvent {
|
||||
data: {
|
||||
type: 'init';
|
||||
};
|
||||
}
|
||||
|
||||
interface CancelEvent extends MessageEvent {
|
||||
data: {
|
||||
type: 'cancel';
|
||||
value: number;
|
||||
};
|
||||
}
|
||||
|
||||
let cachedVersion: string | null = null;
|
||||
|
||||
async function setupTypeScript(version: string) {
|
||||
// TODO: make sure no racing happens if multiple inits arrive at once.
|
||||
if (cachedVersion == version) return tsEnv;
|
||||
|
||||
importScripts('https://unpkg.com/typescript@' + version);
|
||||
importScripts('https://unpkg.com/@typescript/vfs@1.6.0/dist/vfs.globals.js');
|
||||
|
||||
const compilerOptions: CompilerOptions = {
|
||||
target: ts.ScriptTarget.ES2015,
|
||||
skipLibCheck: true // TODO: look into why this is needed. Are we doing something wrong? Could it be that it's not "synced" with this TS version?
|
||||
// from the docs: "Note: it's possible for this list to get out of
|
||||
// sync with TypeScript over time. It was last synced with TypeScript
|
||||
// 3.8.0-rc."
|
||||
};
|
||||
const fsMap = await createDefaultMapFromCDN(
|
||||
compilerOptions,
|
||||
ts.version,
|
||||
false, // TODO: cache this. It needs a store that's available to workers and implements https://github.com/microsoft/TypeScript-Website/blob/ac68b8b8e4a621113c4ee45c4051002fd55ede24/packages/typescript-vfs/src/index.ts#L11
|
||||
ts
|
||||
);
|
||||
|
||||
const system = tsvfs.createSystem(fsMap);
|
||||
// TODO: if passed an invalid compiler options object (e.g. { module:
|
||||
// ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext
|
||||
// }), this will throw. When we allow users to set compiler options, we should
|
||||
// show them the diagnostics from this function.
|
||||
const env = tsvfs.createVirtualTypeScriptEnvironment(
|
||||
system,
|
||||
[],
|
||||
ts,
|
||||
compilerOptions
|
||||
);
|
||||
compilerHost = createVirtualCompilerHost(
|
||||
system,
|
||||
compilerOptions,
|
||||
ts
|
||||
).compilerHost;
|
||||
|
||||
tsEnv = env;
|
||||
|
||||
// We freeze this to prevent learners from getting the worker into a
|
||||
// weird state.
|
||||
Object.freeze(self);
|
||||
|
||||
cachedVersion = version;
|
||||
return env;
|
||||
}
|
||||
|
||||
// TODO: figure out how to start setting up TS in the background, but allow the
|
||||
// client to wait for it to be ready. Currently the waiting works, but the setup
|
||||
// is done on demand.
|
||||
// void setupTypeScript('5');
|
||||
|
||||
ctx.onmessage = (e: TSCompileEvent | InitRequestEvent | CancelEvent) => {
|
||||
const { data, ports } = e;
|
||||
if (data.type === 'init') {
|
||||
void handleInitRequest(ports[0]);
|
||||
} else if (data.type === 'cancel') {
|
||||
handleCancelRequest(data);
|
||||
} else {
|
||||
handleCompileRequest(data, ports[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// This lets the client know that there is nothing to cancel.
|
||||
function handleCancelRequest({ value }: { value: number }) {
|
||||
postMessage({ type: 'is-alive', text: value });
|
||||
}
|
||||
|
||||
async function handleInitRequest(port: MessagePort) {
|
||||
await setupTypeScript('5');
|
||||
port.postMessage({ type: 'ready' });
|
||||
}
|
||||
|
||||
function handleCompileRequest(data: TSCompileEvent['data'], port: MessagePort) {
|
||||
// If we try to update or create an empty file, the environment will become
|
||||
// permanently unable to interact with that file. The workaround is to create
|
||||
// a file with a single newline character.
|
||||
const code = (data.code || '').slice() || '\n';
|
||||
|
||||
// TODO: If creating the file fresh each time is too slow, we can try checking
|
||||
// if the file exists and updating it if it does.
|
||||
tsEnv?.createFile('index.ts', code);
|
||||
|
||||
const program = tsEnv!.languageService.getProgram()!;
|
||||
|
||||
const emitOutput = tsEnv!.languageService.getEmitOutput('index.ts');
|
||||
const compiled = emitOutput.outputFiles[0].text;
|
||||
|
||||
const message: TSCompiledMessage = {
|
||||
type: 'compiled',
|
||||
value: compiled,
|
||||
// TODO: stop forcing the non-null assertions here.
|
||||
error: ts.formatDiagnostics(
|
||||
ts.getPreEmitDiagnostics(program),
|
||||
compilerHost!
|
||||
)
|
||||
};
|
||||
|
||||
port.postMessage(message);
|
||||
}
|
||||
@@ -18,7 +18,8 @@ module.exports = (env = {}) => {
|
||||
'sass-compile': './sass-compile.ts',
|
||||
'test-evaluator': './test-evaluator.ts',
|
||||
'python-worker': './python-worker.ts',
|
||||
'python-test-evaluator': './python-test-evaluator.ts'
|
||||
'python-test-evaluator': './python-test-evaluator.ts',
|
||||
'typescript-worker': './typescript-worker.ts'
|
||||
},
|
||||
devtool: __DEV__ ? 'inline-source-map' : 'source-map',
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user