mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): tsx compilation (#62236)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -54,6 +54,7 @@ const config = {
|
||||
'sql',
|
||||
'svg',
|
||||
'typescript',
|
||||
'tsx',
|
||||
'xml'
|
||||
],
|
||||
theme: 'default',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as ReactDOMServer from 'react-dom/server';
|
||||
import Loadable from '@loadable/component';
|
||||
|
||||
// eslint-disable-next-line import/no-duplicates
|
||||
import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import type {
|
||||
@@ -68,10 +69,18 @@ import { getScrollbarWidth } from '../../../utils/scrollbar-width';
|
||||
import { isProjectBased } from '../../../utils/curriculum-layout';
|
||||
import envConfig from '../../../../config/env.json';
|
||||
import LowerJaw from './lower-jaw';
|
||||
// Direct from npm, license in react-types-licence
|
||||
import reactTypes from './react-types.json';
|
||||
|
||||
import './editor.css';
|
||||
|
||||
const MonacoEditor = Loadable(() => import('react-monaco-editor'));
|
||||
|
||||
const monacoModelFileMap = {
|
||||
tsxFile: 'index.tsx',
|
||||
reactTypes: 'react.d.ts'
|
||||
};
|
||||
|
||||
export interface EditorProps {
|
||||
attempts: number;
|
||||
canFocus: boolean;
|
||||
@@ -353,15 +362,44 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
const { usesMultifileEditor = false } = props;
|
||||
|
||||
monacoRef.current = monaco;
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(),
|
||||
jsx: monaco.languages.typescript.JsxEmit.Preserve,
|
||||
allowUmdGlobalAccess: true
|
||||
});
|
||||
|
||||
defineMonacoThemes(monaco, { usesMultifileEditor });
|
||||
// If a model is not provided, then the editor 'owns' the model it creates
|
||||
// and will dispose of that model if it is replaced. Since we intend to
|
||||
// swap and reuse models, we have to create our own models to prevent
|
||||
// disposal.
|
||||
|
||||
const setupTSModels = (monaco: typeof monacoEditor) => {
|
||||
const reactFile = monaco.Uri.file(monacoModelFileMap.reactTypes);
|
||||
monaco.editor.createModel(
|
||||
reactTypes['react-18'],
|
||||
'typescript',
|
||||
reactFile
|
||||
);
|
||||
|
||||
const file = monaco.Uri.file(monacoModelFileMap.tsxFile);
|
||||
return monaco.editor.createModel('', 'typescript', file);
|
||||
};
|
||||
|
||||
// TODO: make sure these aren't getting created over and over
|
||||
function createModel(contents: string, language: string) {
|
||||
if (language !== 'typescript') {
|
||||
return monaco.editor.createModel(contents, language);
|
||||
} else {
|
||||
const model = setupTSModels(monaco);
|
||||
model.setValue(contents);
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
const model =
|
||||
dataRef.current.model ||
|
||||
monaco.editor.createModel(
|
||||
createModel(
|
||||
challengeFile?.contents ?? '',
|
||||
modeMap[challengeFile?.ext ?? 'html']
|
||||
);
|
||||
@@ -1328,6 +1366,15 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
<MonacoEditor
|
||||
editorDidMount={editorDidMount}
|
||||
editorWillMount={editorWillMount}
|
||||
editorWillUnmount={(editor, monaco) => {
|
||||
const reactFile = monaco.Uri.file(monacoModelFileMap.reactTypes);
|
||||
const file = monaco.Uri.file(monacoModelFileMap.tsxFile);
|
||||
// Any model we've created has to be manually disposed of to prevent
|
||||
// memory leaks.
|
||||
editor.getModel()?.dispose();
|
||||
monaco.editor.getModel(reactFile)?.dispose();
|
||||
monaco.editor.getModel(file)?.dispose();
|
||||
}}
|
||||
onChange={onChange}
|
||||
options={{ ...options, folding: !hasEditableRegion() }}
|
||||
theme={editorTheme}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
File diff suppressed because one or more lines are too long
@@ -96,13 +96,20 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
|
||||
|
||||
const testJS = matchesProperty('ext', 'js');
|
||||
const testJSX = matchesProperty('ext', 'jsx');
|
||||
const testTSX = matchesProperty('ext', 'tsx');
|
||||
const testTypeScript = matchesProperty('ext', 'ts');
|
||||
const testHTML = matchesProperty('ext', 'html');
|
||||
const testHTML$JS$JSX$TS = overSome(testHTML, testJS, testJSX, testTypeScript);
|
||||
const testHTML$JS$JSX$TS$TSX = overSome(
|
||||
testHTML,
|
||||
testJS,
|
||||
testJSX,
|
||||
testTypeScript,
|
||||
testTSX
|
||||
);
|
||||
|
||||
const replaceNBSP = cond([
|
||||
[
|
||||
testHTML$JS$JSX$TS,
|
||||
testHTML$JS$JSX$TS$TSX,
|
||||
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
|
||||
],
|
||||
[stubTrue, identity]
|
||||
@@ -150,6 +157,22 @@ const getTSTranspiler = loopProtectOptions => async challengeFile => {
|
||||
)(challengeFile);
|
||||
};
|
||||
|
||||
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
|
||||
await loadBabel();
|
||||
await loadPresetReact();
|
||||
await checkTSServiceIsReady();
|
||||
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
|
||||
const babelOptions = {
|
||||
...baseOptions,
|
||||
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
|
||||
moduleId: 'index' // TODO: this should be dynamic
|
||||
};
|
||||
return flow(
|
||||
partial(transformHeadTailAndContents, compileTypeScriptCode),
|
||||
partial(transformHeadTailAndContents, babelTransformCode(babelOptions))
|
||||
)(challengeFile);
|
||||
};
|
||||
|
||||
const createTranspiler = loopProtectOptions => {
|
||||
return cond([
|
||||
[testJS, getJSTranspiler(loopProtectOptions)],
|
||||
@@ -163,6 +186,7 @@ const createTranspiler = loopProtectOptions => {
|
||||
const createModuleTransformer = loopProtectOptions => {
|
||||
return cond([
|
||||
[testJSX, getJSXModuleTranspiler(loopProtectOptions)],
|
||||
[testTSX, getTSXModuleTranspiler(loopProtectOptions)],
|
||||
[testHTML, getHtmlTranspiler({ useModules: true })],
|
||||
[stubTrue, identity]
|
||||
]);
|
||||
@@ -242,7 +266,7 @@ async function transformScript(documentElement, { useModules }) {
|
||||
// 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, indexTs } =
|
||||
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs, indexTsx } =
|
||||
challengeFilesToObject(challengeFiles);
|
||||
|
||||
const embedStylesAndScript = contentDocument => {
|
||||
@@ -266,6 +290,14 @@ export const embedFilesInHtml = async function (challengeFiles) {
|
||||
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.jsx"]`
|
||||
);
|
||||
|
||||
const tsxScript =
|
||||
documentElement.querySelector(
|
||||
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.tsx"]`
|
||||
) ??
|
||||
documentElement.querySelector(
|
||||
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.tsx"]`
|
||||
);
|
||||
|
||||
if (link) {
|
||||
const style = contentDocument.createElement('style');
|
||||
style.classList.add('fcc-injected-styles');
|
||||
@@ -293,6 +325,13 @@ export const embedFilesInHtml = async function (challengeFiles) {
|
||||
jsxScript.setAttribute('data-src', 'index.jsx');
|
||||
jsxScript.setAttribute('data-type', 'text/babel');
|
||||
}
|
||||
if (tsxScript) {
|
||||
tsxScript.innerHTML = indexTsx?.contents;
|
||||
tsxScript.removeAttribute('src');
|
||||
tsxScript.removeAttribute('type');
|
||||
tsxScript.setAttribute('data-src', 'index.tsx');
|
||||
tsxScript.setAttribute('data-type', 'text/babel');
|
||||
}
|
||||
return documentElement.innerHTML;
|
||||
};
|
||||
|
||||
@@ -308,8 +347,10 @@ export const embedFilesInHtml = async function (challengeFiles) {
|
||||
return `<script>${scriptJs.contents}</script>`;
|
||||
} else if (indexTs) {
|
||||
return `<script>${indexTs.contents}</script>`;
|
||||
} else if (indexTsx) {
|
||||
return `<script>${indexTsx.contents}</script>`;
|
||||
} else {
|
||||
throw Error('No html, ts or js(x) file found');
|
||||
throw Error('No html, ts(x) or js(x) file found');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,7 +360,8 @@ function challengeFilesToObject(challengeFiles) {
|
||||
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
|
||||
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
|
||||
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
|
||||
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs };
|
||||
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
|
||||
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs, indexTsx };
|
||||
}
|
||||
|
||||
const parseAndTransform = async function (transform, contents) {
|
||||
|
||||
@@ -186,7 +186,7 @@ export async function buildDOMChallenge(
|
||||
// TODO: make this required in the schema.
|
||||
if (!challengeFiles) throw Error('No challenge files provided');
|
||||
const hasJsx = challengeFiles.some(
|
||||
challengeFile => challengeFile.ext === 'jsx'
|
||||
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
|
||||
);
|
||||
const isMultifile = challengeFiles.length > 1;
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ export function enhancePrismAccessibility(
|
||||
json: 'JSON',
|
||||
pug: 'pug',
|
||||
ts: 'TypeScript',
|
||||
typescript: 'TypeScript'
|
||||
typescript: 'TypeScript',
|
||||
tsx: 'TSX'
|
||||
};
|
||||
const parent = prismEnv?.element?.parentElement;
|
||||
if (
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
import { type VirtualTypeScriptEnvironment } from '@typescript/vfs';
|
||||
import type { CompilerOptions, CompilerHost } from 'typescript';
|
||||
import reactTypes from './react-types.json';
|
||||
|
||||
// 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
|
||||
@@ -40,7 +41,7 @@ interface CancelEvent extends MessageEvent {
|
||||
}
|
||||
|
||||
// Pin at the latest TS version available as cdnjs doesn't support version range.
|
||||
const TS_VERSION = '5.7.3';
|
||||
const TS_VERSION = '5.9.2';
|
||||
|
||||
let tsEnv: VirtualTypeScriptEnvironment | null = null;
|
||||
let compilerHost: CompilerHost | null = null;
|
||||
@@ -54,7 +55,12 @@ importScripts(
|
||||
function importTS(version: string) {
|
||||
if (cachedVersion == version) return;
|
||||
importScripts(
|
||||
`https://cdnjs.cloudflare.com/ajax/libs/typescript/${version}/typescript.min.js`
|
||||
/* typescript.min.js fails with
|
||||
|
||||
typescript.min.js:320 Uncaught TypeError: Class constructors cannot be invoked without 'new'
|
||||
|
||||
so we're using the non-minified version for now. */
|
||||
`https://cdnjs.cloudflare.com/ajax/libs/typescript/${version}/typescript.js`
|
||||
);
|
||||
cachedVersion = version;
|
||||
}
|
||||
@@ -63,10 +69,13 @@ async function setupTypeScript() {
|
||||
importTS(TS_VERSION);
|
||||
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?
|
||||
module: ts.ModuleKind.Preserve, // Babel is handling module transformation, so TS should leave them alone.
|
||||
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."
|
||||
jsx: ts.JsxEmit.Preserve, // Babel will handle JSX,
|
||||
allowUmdGlobalAccess: true // Necessary because React is loaded via a UMD script.
|
||||
};
|
||||
const fsMap = await createDefaultMapFromCDN(
|
||||
compilerOptions,
|
||||
@@ -75,6 +84,12 @@ async function setupTypeScript() {
|
||||
ts
|
||||
);
|
||||
|
||||
// This can be any path, but doing this means import React from 'react' works, if we ever need it.
|
||||
const reactTypesPath = `/node_modules/@types/react/index.d.ts`;
|
||||
|
||||
// It may be necessary to get all the types (global.d.ts etc)
|
||||
fsMap.set(reactTypesPath, reactTypes['react-18'] || '');
|
||||
|
||||
const system = tsvfs.createSystem(fsMap);
|
||||
// TODO: if passed an invalid compiler options object (e.g. { module:
|
||||
// ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext
|
||||
@@ -82,10 +97,11 @@ async function setupTypeScript() {
|
||||
// show them the diagnostics from this function.
|
||||
const env = tsvfs.createVirtualTypeScriptEnvironment(
|
||||
system,
|
||||
[],
|
||||
[reactTypesPath],
|
||||
ts,
|
||||
compilerOptions
|
||||
);
|
||||
|
||||
compilerHost = createVirtualCompilerHost(
|
||||
system,
|
||||
compilerOptions,
|
||||
@@ -133,11 +149,12 @@ function handleCompileRequest(data: TSCompileEvent['data'], port: MessagePort) {
|
||||
|
||||
// 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);
|
||||
// TODO: make sure the .tsx extension doesn't cause issues with vanilla TS.
|
||||
tsEnv?.createFile('/index.tsx', code);
|
||||
|
||||
const program = tsEnv!.languageService.getProgram()!;
|
||||
|
||||
const emitOutput = tsEnv!.languageService.getEmitOutput('index.ts');
|
||||
const emitOutput = tsEnv!.languageService.getEmitOutput('index.tsx');
|
||||
const compiled = emitOutput.outputFiles[0].text;
|
||||
|
||||
const message: TSCompiledMessage = {
|
||||
|
||||
Reference in New Issue
Block a user