Skip to content

Commit 9356588

Browse files
feat(client): add tsconfig support to editor and use it in ts compiler (freeCodeCamp#66259)
1 parent c9071dd commit 9356588

17 files changed

Lines changed: 213 additions & 41 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ const modeMap = {
232232
ts: 'typescript',
233233
tsx: 'typescript',
234234
py: 'python',
235-
python: 'python'
235+
python: 'python',
236+
json: 'json'
236237
};
237238

238239
let monacoThemesDefined = false;
@@ -413,6 +414,11 @@ const Editor = (props: EditorProps): JSX.Element => {
413414
allowUmdGlobalAccess: true
414415
});
415416

417+
// support JSONC:
418+
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
419+
allowComments: true
420+
});
421+
416422
defineMonacoThemes(monaco, { usesMultifileEditor });
417423
// If a model is not provided, then the editor 'owns' the model it creates
418424
// and will dispose of that model if it is replaced. Since we intend to

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type VisibleEditors = {
1919
indexts?: boolean;
2020
indextsx?: boolean;
2121
mainpy?: boolean;
22+
tsconfigjson?: boolean;
2223
};
2324
type MultifileEditorProps = Pick<
2425
EditorProps,
@@ -72,7 +73,8 @@ const MultifileEditor = (props: MultifileEditorProps) => {
7273
indexts,
7374
indexjsx,
7475
indextsx,
75-
mainpy
76+
mainpy,
77+
tsconfigjson
7678
},
7779
usesMultifileEditor,
7880
showProjectPreview,
@@ -102,6 +104,7 @@ const MultifileEditor = (props: MultifileEditorProps) => {
102104
if (scriptjs) editorKeys.push('scriptjs');
103105
if (mainpy) editorKeys.push('mainpy');
104106
if (indexts) editorKeys.push('indexts');
107+
if (tsconfigjson) editorKeys.push('tsconfigjson');
105108

106109
const editorAndSplitterKeys = editorKeys.reduce((acc: string[] | [], key) => {
107110
if (acc.length === 0) {

client/utils/__fixtures__/challenges.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChallengeFile } from "../../src/redux/prop-types";
1+
import { ChallengeFile } from '../../src/redux/prop-types';
22

33
export const challengeFiles: ChallengeFile[] = [
44
{
@@ -13,7 +13,7 @@ export const challengeFiles: ChallengeFile[] = [
1313
tail: '',
1414
editableRegionBoundaries: [],
1515
usesMultifileEditor: true,
16-
path: 'index.ts',
16+
path: 'index.ts'
1717
},
1818
{
1919
contents: 'some css',
@@ -27,7 +27,7 @@ export const challengeFiles: ChallengeFile[] = [
2727
tail: '',
2828
editableRegionBoundaries: [],
2929
usesMultifileEditor: true,
30-
path: 'styles.css',
30+
path: 'styles.css'
3131
},
3232
{
3333
contents: 'some html',
@@ -41,7 +41,7 @@ export const challengeFiles: ChallengeFile[] = [
4141
tail: '',
4242
editableRegionBoundaries: [],
4343
usesMultifileEditor: true,
44-
path: 'index.html',
44+
path: 'index.html'
4545
},
4646
{
4747
contents: 'some js',
@@ -55,7 +55,7 @@ export const challengeFiles: ChallengeFile[] = [
5555
tail: '',
5656
editableRegionBoundaries: [],
5757
usesMultifileEditor: true,
58-
path: 'script.js',
58+
path: 'script.js'
5959
},
6060
{
6161
contents: 'some jsx',
@@ -69,7 +69,7 @@ export const challengeFiles: ChallengeFile[] = [
6969
tail: '',
7070
editableRegionBoundaries: [],
7171
usesMultifileEditor: true,
72-
path: 'index.jsx',
72+
path: 'index.jsx'
7373
},
7474
{
7575
contents: 'some tsx',
@@ -83,6 +83,20 @@ export const challengeFiles: ChallengeFile[] = [
8383
tail: '',
8484
editableRegionBoundaries: [],
8585
usesMultifileEditor: true,
86-
path: 'index.tsx',
86+
path: 'index.tsx'
87+
},
88+
{
89+
contents: '{\n "compilerOptions": {}\n}',
90+
error: null,
91+
ext: 'json',
92+
head: '',
93+
history: ['tsconfig.json'],
94+
fileKey: 'tsconfigjson',
95+
name: 'tsconfig',
96+
seed: '{\n "compilerOptions": {}\n}',
97+
tail: '',
98+
editableRegionBoundaries: [],
99+
usesMultifileEditor: true,
100+
path: 'tsconfig.json'
87101
}
88-
]
102+
];

client/utils/sort-challengefiles.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('sort-files', () => {
1515
expect(sorted.length).toEqual(expected.length);
1616
});
1717

18-
it('should sort the objects into jsx, tsx, html, css, js, ts order', () => {
18+
it('should sort the objects into jsx, tsx, html, css, js, ts, tsconfig order', () => {
1919
const sorted = sortChallengeFiles(challengeFiles);
2020
const sortedKeys = sorted.map(({ fileKey }) => fileKey);
2121
const expected = [
@@ -24,7 +24,8 @@ describe('sort-files', () => {
2424
'indexhtml',
2525
'stylescss',
2626
'scriptjs',
27-
'indexts'
27+
'indexts',
28+
'tsconfigjson'
2829
];
2930
expect(sortedKeys).toStrictEqual(expected);
3031
});

client/utils/sort-challengefiles.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export function sortChallengeFiles<File extends { fileKey: string }>(
1414
if (b.fileKey === 'scriptjs') return 1;
1515
if (a.fileKey === 'indexts') return -1;
1616
if (b.fileKey === 'indexts') return 1;
17+
if (a.fileKey === 'tsconfigjson') return -1;
18+
if (b.fileKey === 'tsconfigjson') return 1;
1719
return 0;
1820
});
1921
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getTSConfig } from './build';
3+
import { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
4+
5+
describe('getTSConfig', () => {
6+
it("should return the tsconfig file's contents if it exists", () => {
7+
const compileOptions = 'any string is valid here';
8+
const challengeFiles = [
9+
{ name: 'index', ext: 'ts' },
10+
{ name: 'tsconfig', ext: 'json', contents: compileOptions }
11+
] as ChallengeFile[];
12+
13+
expect(getTSConfig(challengeFiles)).toEqual(compileOptions);
14+
});
15+
16+
it('should return null if there is no tsconfig file', () => {
17+
const challengeFiles = [
18+
{ name: 'index', ext: 'ts' },
19+
{ name: 'app', ext: 'ts' }
20+
] as ChallengeFile[];
21+
22+
expect(getTSConfig(challengeFiles)).toBeNull();
23+
});
24+
25+
it('should throw an error if there are multiple tsconfig.json files', () => {
26+
const challengeFiles = [
27+
{ name: 'index', ext: 'ts' },
28+
{ name: 'tsconfig', ext: 'json' },
29+
{ name: 'tsconfig', ext: 'json' }
30+
] as ChallengeFile[];
31+
32+
expect(() => getTSConfig(challengeFiles)).toThrow(
33+
'TypeScript challenge must include only one tsconfig.json file'
34+
);
35+
});
36+
});

packages/challenge-builder/src/build.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getPythonTransformers,
99
getMultifileJSXTransformers
1010
} from './transformers.js';
11+
import { setupTSCompiler } from './typescript-worker-handler.js';
1112

1213
interface Source {
1314
index: string;
@@ -165,6 +166,39 @@ type BuildResult = {
165166
error?: unknown;
166167
};
167168

169+
function hasTS(challengeFiles: ChallengeFile[]) {
170+
return challengeFiles.some(
171+
challengeFile => challengeFile.ext === 'ts' || challengeFile.ext === 'tsx'
172+
);
173+
}
174+
175+
const isTSConfig = (f: { name: string; ext: string }) =>
176+
f.name === 'tsconfig' && f.ext === 'json';
177+
178+
export function getTSConfig(challengeFiles: ChallengeFile[]) {
179+
const tsConfigFiles = challengeFiles.filter(isTSConfig);
180+
181+
if (tsConfigFiles.length > 1) {
182+
throw new Error(
183+
'TypeScript challenge must include only one tsconfig.json file'
184+
);
185+
}
186+
187+
return tsConfigFiles.length === 1 ? tsConfigFiles[0].contents : null;
188+
}
189+
190+
async function configureTSCompiler(challengeFiles: ChallengeFile[]) {
191+
if (hasTS(challengeFiles)) {
192+
const tsConfig = getTSConfig(challengeFiles);
193+
194+
if (tsConfig) {
195+
await setupTSCompiler(tsConfig);
196+
} else {
197+
await setupTSCompiler();
198+
}
199+
}
200+
}
201+
168202
// TODO: All the buildXChallenge files have a similar structure, so make that
169203
// abstraction (function, class, whatever) and then create the various functions
170204
// out of it.
@@ -182,20 +216,18 @@ async function buildDOMChallenge(
182216
const hasJsx = challengeFiles.some(
183217
challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx'
184218
);
185-
const isMultifile = challengeFiles.length > 1;
186-
187-
const requiresReact16 = required.some(({ src }) =>
188-
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
189-
);
190219

220+
await configureTSCompiler(challengeFiles);
221+
const sourceFiles = challengeFiles.filter(file => !isTSConfig(file));
222+
const isMultifile = sourceFiles.length > 1;
191223
// I'm reasonably sure this is fine, but we need to migrate transformers to
192224
// TypeScript to be sure.
193225
const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx
194226
? getMultifileJSXTransformers(options)
195227
: getTransformers(options)) as unknown as ApplyFunctionProps[];
196228

197229
const pipeLine = composeFunctions(...transformers);
198-
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
230+
const finalFiles = await Promise.all(sourceFiles.map(pipeLine));
199231
const error = finalFiles.find(({ error }) => error)?.error;
200232
const contents = (await embedFilesInHtml(finalFiles)) as string;
201233

@@ -209,6 +241,10 @@ async function buildDOMChallenge(
209241
contents
210242
};
211243

244+
const requiresReact16 = required.some(({ src }) =>
245+
src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.')
246+
);
247+
212248
return {
213249
challengeType,
214250
build: concatHtml(toBuild),
@@ -230,7 +266,9 @@ async function buildJSChallenge(
230266
...(getTransformers(options) as unknown as ApplyFunctionProps[])
231267
);
232268

233-
const finalFiles = await Promise.all(challengeFiles?.map(pipeLine));
269+
await configureTSCompiler(challengeFiles);
270+
const sourceFiles = challengeFiles.filter(file => !isTSConfig(file));
271+
const finalFiles = await Promise.all(sourceFiles?.map(pipeLine));
234272
const error = finalFiles.find(({ error }) => error)?.error;
235273

236274
const toBuild = error ? [] : finalFiles;

packages/challenge-builder/src/transformers.js

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ import {
1818
import { version } from '@freecodecamp/browser-scripts/package.json';
1919

2020
import { WorkerExecutor } from './worker-executor';
21-
import {
22-
compileTypeScriptCode,
23-
setupTSCompiler
24-
} from './typescript-worker-handler';
21+
import { compileTypeScriptCode } from './typescript-worker-handler';
2522

2623
const protectTimeout = 100;
2724
const testProtectTimeout = 1500;
@@ -148,7 +145,6 @@ const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => {
148145

149146
const getTSTranspiler = loopProtectOptions => async challengeFile => {
150147
await loadBabel();
151-
await setupTSCompiler();
152148
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
153149
return flow(
154150
partial(transformHeadTailAndContents, compileTypeScriptCode),
@@ -159,7 +155,6 @@ const getTSTranspiler = loopProtectOptions => async challengeFile => {
159155
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
160156
await loadBabel();
161157
await loadPresetReact();
162-
await setupTSCompiler();
163158
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
164159
const babelOptions = {
165160
...baseOptions,
@@ -379,7 +374,18 @@ function challengeFilesToObject(challengeFiles) {
379374
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
380375
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
381376
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
382-
return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs, indexTsx };
377+
const tsconfigJson = challengeFiles.find(
378+
file => file.fileKey === 'tsconfigjson'
379+
);
380+
return {
381+
indexHtml,
382+
indexJsx,
383+
stylesCss,
384+
scriptJs,
385+
indexTs,
386+
indexTsx,
387+
tsconfigJson
388+
};
383389
}
384390

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

packages/challenge-builder/src/typescript-worker-handler.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@ export function compileTypeScriptCode(code: string): Promise<string> {
3131
});
3232
}
3333

34-
export function setupTSCompiler(
35-
compilerOptions?: Record<string, unknown>
36-
): Promise<boolean> {
34+
export function setupTSCompiler(tsconfig?: string): Promise<boolean> {
3735
return awaitResponse({
3836
messenger: getTypeScriptWorker(),
39-
message: { type: 'setup', ...(compilerOptions && { compilerOptions }) },
37+
message: { type: 'setup', ...(tsconfig && { tsconfig }) },
4038
onMessage: (data, onSuccess) => {
4139
if (data.type === 'ready') {
4240
onSuccess(true);

packages/shared/src/utils/polyvinyl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const exts = ['js', 'html', 'css', 'jsx', 'ts', 'tsx', 'py'] as const;
1+
const exts = ['js', 'html', 'css', 'jsx', 'ts', 'tsx', 'py', 'json'] as const;
22
export type Ext = (typeof exts)[number];
33

44
export interface IncompleteChallengeFile {

0 commit comments

Comments
 (0)