Skip to content

Commit af70a07

Browse files
feat(client): tsx compilation (freeCodeCamp#62236)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
1 parent b45ed39 commit af70a07

File tree

9 files changed

+149
-14
lines changed

9 files changed

+149
-14
lines changed

client/.babelrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const config = {
5454
'sql',
5555
'svg',
5656
'typescript',
57+
'tsx',
5758
'xml'
5859
],
5960
theme: 'default',

client/src/templates/Challenges/classic/editor.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as ReactDOMServer from 'react-dom/server';
22
import Loadable from '@loadable/component';
3+
34
// eslint-disable-next-line import/no-duplicates
45
import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api';
56
import type {
@@ -68,10 +69,18 @@ import { getScrollbarWidth } from '../../../utils/scrollbar-width';
6869
import { isProjectBased } from '../../../utils/curriculum-layout';
6970
import envConfig from '../../../../config/env.json';
7071
import LowerJaw from './lower-jaw';
72+
// Direct from npm, license in react-types-licence
73+
import reactTypes from './react-types.json';
74+
7175
import './editor.css';
7276

7377
const MonacoEditor = Loadable(() => import('react-monaco-editor'));
7478

79+
const monacoModelFileMap = {
80+
tsxFile: 'index.tsx',
81+
reactTypes: 'react.d.ts'
82+
};
83+
7584
export interface EditorProps {
7685
attempts: number;
7786
canFocus: boolean;
@@ -353,15 +362,44 @@ const Editor = (props: EditorProps): JSX.Element => {
353362
const { usesMultifileEditor = false } = props;
354363

355364
monacoRef.current = monaco;
365+
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
366+
...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(),
367+
jsx: monaco.languages.typescript.JsxEmit.Preserve,
368+
allowUmdGlobalAccess: true
369+
});
370+
356371
defineMonacoThemes(monaco, { usesMultifileEditor });
357372
// If a model is not provided, then the editor 'owns' the model it creates
358373
// and will dispose of that model if it is replaced. Since we intend to
359374
// swap and reuse models, we have to create our own models to prevent
360375
// disposal.
361376

377+
const setupTSModels = (monaco: typeof monacoEditor) => {
378+
const reactFile = monaco.Uri.file(monacoModelFileMap.reactTypes);
379+
monaco.editor.createModel(
380+
reactTypes['react-18'],
381+
'typescript',
382+
reactFile
383+
);
384+
385+
const file = monaco.Uri.file(monacoModelFileMap.tsxFile);
386+
return monaco.editor.createModel('', 'typescript', file);
387+
};
388+
389+
// TODO: make sure these aren't getting created over and over
390+
function createModel(contents: string, language: string) {
391+
if (language !== 'typescript') {
392+
return monaco.editor.createModel(contents, language);
393+
} else {
394+
const model = setupTSModels(monaco);
395+
model.setValue(contents);
396+
return model;
397+
}
398+
}
399+
362400
const model =
363401
dataRef.current.model ||
364-
monaco.editor.createModel(
402+
createModel(
365403
challengeFile?.contents ?? '',
366404
modeMap[challengeFile?.ext ?? 'html']
367405
);
@@ -1328,6 +1366,15 @@ const Editor = (props: EditorProps): JSX.Element => {
13281366
<MonacoEditor
13291367
editorDidMount={editorDidMount}
13301368
editorWillMount={editorWillMount}
1369+
editorWillUnmount={(editor, monaco) => {
1370+
const reactFile = monaco.Uri.file(monacoModelFileMap.reactTypes);
1371+
const file = monaco.Uri.file(monacoModelFileMap.tsxFile);
1372+
// Any model we've created has to be manually disposed of to prevent
1373+
// memory leaks.
1374+
editor.getModel()?.dispose();
1375+
monaco.editor.getModel(reactFile)?.dispose();
1376+
monaco.editor.getModel(file)?.dispose();
1377+
}}
13311378
onChange={onChange}
13321379
options={{ ...options, folding: !hasEditableRegion() }}
13331380
theme={editorTheme}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) Microsoft Corporation.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE

client/src/templates/Challenges/classic/react-types.json

Lines changed: 3 additions & 0 deletions
Large diffs are not rendered by default.

client/src/templates/Challenges/rechallenge/transformers.js

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,20 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g');
9696

9797
const testJS = matchesProperty('ext', 'js');
9898
const testJSX = matchesProperty('ext', 'jsx');
99+
const testTSX = matchesProperty('ext', 'tsx');
99100
const testTypeScript = matchesProperty('ext', 'ts');
100101
const testHTML = matchesProperty('ext', 'html');
101-
const testHTML$JS$JSX$TS = overSome(testHTML, testJS, testJSX, testTypeScript);
102+
const testHTML$JS$JSX$TS$TSX = overSome(
103+
testHTML,
104+
testJS,
105+
testJSX,
106+
testTypeScript,
107+
testTSX
108+
);
102109

103110
const replaceNBSP = cond([
104111
[
105-
testHTML$JS$JSX$TS,
112+
testHTML$JS$JSX$TS$TSX,
106113
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
107114
],
108115
[stubTrue, identity]
@@ -150,6 +157,22 @@ const getTSTranspiler = loopProtectOptions => async challengeFile => {
150157
)(challengeFile);
151158
};
152159

160+
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
161+
await loadBabel();
162+
await loadPresetReact();
163+
await checkTSServiceIsReady();
164+
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
165+
const babelOptions = {
166+
...baseOptions,
167+
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
168+
moduleId: 'index' // TODO: this should be dynamic
169+
};
170+
return flow(
171+
partial(transformHeadTailAndContents, compileTypeScriptCode),
172+
partial(transformHeadTailAndContents, babelTransformCode(babelOptions))
173+
)(challengeFile);
174+
};
175+
153176
const createTranspiler = loopProtectOptions => {
154177
return cond([
155178
[testJS, getJSTranspiler(loopProtectOptions)],
@@ -163,6 +186,7 @@ const createTranspiler = loopProtectOptions => {
163186
const createModuleTransformer = loopProtectOptions => {
164187
return cond([
165188
[testJSX, getJSXModuleTranspiler(loopProtectOptions)],
189+
[testTSX, getTSXModuleTranspiler(loopProtectOptions)],
166190
[testHTML, getHtmlTranspiler({ useModules: true })],
167191
[stubTrue, identity]
168192
]);
@@ -242,7 +266,7 @@ async function transformScript(documentElement, { useModules }) {
242266
// This does the final transformations of the files needed to embed them into
243267
// HTML.
244268
export const embedFilesInHtml = async function (challengeFiles) {
245-
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs } =
269+
const { indexHtml, stylesCss, scriptJs, indexJsx, indexTs, indexTsx } =
246270
challengeFilesToObject(challengeFiles);
247271

248272
const embedStylesAndScript = contentDocument => {
@@ -266,6 +290,14 @@ export const embedFilesInHtml = async function (challengeFiles) {
266290
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.jsx"]`
267291
);
268292

293+
const tsxScript =
294+
documentElement.querySelector(
295+
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.tsx"]`
296+
) ??
297+
documentElement.querySelector(
298+
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="./index.tsx"]`
299+
);
300+
269301
if (link) {
270302
const style = contentDocument.createElement('style');
271303
style.classList.add('fcc-injected-styles');
@@ -293,6 +325,13 @@ export const embedFilesInHtml = async function (challengeFiles) {
293325
jsxScript.setAttribute('data-src', 'index.jsx');
294326
jsxScript.setAttribute('data-type', 'text/babel');
295327
}
328+
if (tsxScript) {
329+
tsxScript.innerHTML = indexTsx?.contents;
330+
tsxScript.removeAttribute('src');
331+
tsxScript.removeAttribute('type');
332+
tsxScript.setAttribute('data-src', 'index.tsx');
333+
tsxScript.setAttribute('data-type', 'text/babel');
334+
}
296335
return documentElement.innerHTML;
297336
};
298337

@@ -308,8 +347,10 @@ export const embedFilesInHtml = async function (challengeFiles) {
308347
return `<script>${scriptJs.contents}</script>`;
309348
} else if (indexTs) {
310349
return `<script>${indexTs.contents}</script>`;
350+
} else if (indexTsx) {
351+
return `<script>${indexTsx.contents}</script>`;
311352
} else {
312-
throw Error('No html, ts or js(x) file found');
353+
throw Error('No html, ts(x) or js(x) file found');
313354
}
314355
};
315356

@@ -319,7 +360,8 @@ function challengeFilesToObject(challengeFiles) {
319360
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
320361
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
321362
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
322-
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs };
363+
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
364+
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs, indexTsx };
323365
}
324366

325367
const parseAndTransform = async function (transform, contents) {

client/src/templates/Challenges/utils/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export async function buildDOMChallenge(
186186
// TODO: make this required in the schema.
187187
if (!challengeFiles) throw Error('No challenge files provided');
188188
const hasJsx = challengeFiles.some(
189-
challengeFile => challengeFile.ext === 'jsx'
189+
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
190190
);
191191
const isMultifile = challengeFiles.length > 1;
192192

client/src/templates/Challenges/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export function enhancePrismAccessibility(
5252
json: 'JSON',
5353
pug: 'pug',
5454
ts: 'TypeScript',
55-
typescript: 'TypeScript'
55+
typescript: 'TypeScript',
56+
tsx: 'TSX'
5657
};
5758
const parent = prismEnv?.element?.parentElement;
5859
if (

tools/client-plugins/browser-scripts/react-types.json

Lines changed: 3 additions & 0 deletions
Large diffs are not rendered by default.

tools/client-plugins/browser-scripts/typescript-worker.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type VirtualTypeScriptEnvironment } from '@typescript/vfs';
22
import type { CompilerOptions, CompilerHost } from 'typescript';
3+
import reactTypes from './react-types.json';
34

45
// Most of the ts types are only a guideline. This is because we're not bundling
56
// TS in this worker. The specific TS version is going to be determined by the
@@ -40,7 +41,7 @@ interface CancelEvent extends MessageEvent {
4041
}
4142

4243
// Pin at the latest TS version available as cdnjs doesn't support version range.
43-
const TS_VERSION = '5.7.3';
44+
const TS_VERSION = '5.9.2';
4445

4546
let tsEnv: VirtualTypeScriptEnvironment | null = null;
4647
let compilerHost: CompilerHost | null = null;
@@ -54,7 +55,12 @@ importScripts(
5455
function importTS(version: string) {
5556
if (cachedVersion == version) return;
5657
importScripts(
57-
`https://cdnjs.cloudflare.com/ajax/libs/typescript/${version}/typescript.min.js`
58+
/* typescript.min.js fails with
59+
60+
typescript.min.js:320 Uncaught TypeError: Class constructors cannot be invoked without 'new'
61+
62+
so we're using the non-minified version for now. */
63+
`https://cdnjs.cloudflare.com/ajax/libs/typescript/${version}/typescript.js`
5864
);
5965
cachedVersion = version;
6066
}
@@ -63,10 +69,13 @@ async function setupTypeScript() {
6369
importTS(TS_VERSION);
6470
const compilerOptions: CompilerOptions = {
6571
target: ts.ScriptTarget.ES2015,
66-
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?
72+
module: ts.ModuleKind.Preserve, // Babel is handling module transformation, so TS should leave them alone.
73+
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?
6774
// from the docs: "Note: it's possible for this list to get out of
6875
// sync with TypeScript over time. It was last synced with TypeScript
6976
// 3.8.0-rc."
77+
jsx: ts.JsxEmit.Preserve, // Babel will handle JSX,
78+
allowUmdGlobalAccess: true // Necessary because React is loaded via a UMD script.
7079
};
7180
const fsMap = await createDefaultMapFromCDN(
7281
compilerOptions,
@@ -75,17 +84,24 @@ async function setupTypeScript() {
7584
ts
7685
);
7786

87+
// This can be any path, but doing this means import React from 'react' works, if we ever need it.
88+
const reactTypesPath = `/node_modules/@types/react/index.d.ts`;
89+
90+
// It may be necessary to get all the types (global.d.ts etc)
91+
fsMap.set(reactTypesPath, reactTypes['react-18'] || '');
92+
7893
const system = tsvfs.createSystem(fsMap);
7994
// TODO: if passed an invalid compiler options object (e.g. { module:
8095
// ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext
8196
// }), this will throw. When we allow users to set compiler options, we should
8297
// show them the diagnostics from this function.
8398
const env = tsvfs.createVirtualTypeScriptEnvironment(
8499
system,
85-
[],
100+
[reactTypesPath],
86101
ts,
87102
compilerOptions
88103
);
104+
89105
compilerHost = createVirtualCompilerHost(
90106
system,
91107
compilerOptions,
@@ -133,11 +149,12 @@ function handleCompileRequest(data: TSCompileEvent['data'], port: MessagePort) {
133149

134150
// TODO: If creating the file fresh each time is too slow, we can try checking
135151
// if the file exists and updating it if it does.
136-
tsEnv?.createFile('index.ts', code);
152+
// TODO: make sure the .tsx extension doesn't cause issues with vanilla TS.
153+
tsEnv?.createFile('/index.tsx', code);
137154

138155
const program = tsEnv!.languageService.getProgram()!;
139156

140-
const emitOutput = tsEnv!.languageService.getEmitOutput('index.ts');
157+
const emitOutput = tsEnv!.languageService.getEmitOutput('index.tsx');
141158
const compiled = emitOutput.outputFiles[0].text;
142159

143160
const message: TSCompiledMessage = {

0 commit comments

Comments
 (0)