From 9356588e80f07d310ea60a1665590efeef242f68 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 16 Mar 2026 18:42:24 +0100 Subject: [PATCH] feat(client): add tsconfig support to editor and use it in ts compiler (#66259) --- .../templates/Challenges/classic/editor.tsx | 8 ++- .../Challenges/classic/multifile-editor.tsx | 5 +- client/utils/__fixtures__/challenges.ts | 30 ++++++++--- client/utils/sort-challengefiles.test.ts | 5 +- client/utils/sort-challengefiles.ts | 2 + packages/challenge-builder/src/build.test.ts | 36 +++++++++++++ packages/challenge-builder/src/build.ts | 52 ++++++++++++++++--- .../challenge-builder/src/transformers.js | 20 ++++--- .../src/typescript-worker-handler.ts | 6 +-- packages/shared/src/utils/polyvinyl.ts | 2 +- .../parser/__fixtures__/simple.md | 8 +++ .../index.acceptance.test.js.snap | 13 +++++ .../__snapshots__/add-seed.test.js.snap | 13 +++++ .../parser/plugins/add-seed.test.js | 13 +++++ .../parser/plugins/utils/get-file-visitor.js | 14 ++++- .../modules/typescript-compiler.ts | 22 ++++++-- .../browser-scripts/typescript-worker.ts | 5 +- 17 files changed, 213 insertions(+), 41 deletions(-) create mode 100644 packages/challenge-builder/src/build.test.ts diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index ff14b230e19..70d804bf139 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -232,7 +232,8 @@ const modeMap = { ts: 'typescript', tsx: 'typescript', py: 'python', - python: 'python' + python: 'python', + json: 'json' }; let monacoThemesDefined = false; @@ -413,6 +414,11 @@ const Editor = (props: EditorProps): JSX.Element => { allowUmdGlobalAccess: true }); + // support JSONC: + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + allowComments: 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 diff --git a/client/src/templates/Challenges/classic/multifile-editor.tsx b/client/src/templates/Challenges/classic/multifile-editor.tsx index 18c68837425..84c63e70481 100644 --- a/client/src/templates/Challenges/classic/multifile-editor.tsx +++ b/client/src/templates/Challenges/classic/multifile-editor.tsx @@ -19,6 +19,7 @@ export type VisibleEditors = { indexts?: boolean; indextsx?: boolean; mainpy?: boolean; + tsconfigjson?: boolean; }; type MultifileEditorProps = Pick< EditorProps, @@ -72,7 +73,8 @@ const MultifileEditor = (props: MultifileEditorProps) => { indexts, indexjsx, indextsx, - mainpy + mainpy, + tsconfigjson }, usesMultifileEditor, showProjectPreview, @@ -102,6 +104,7 @@ const MultifileEditor = (props: MultifileEditorProps) => { if (scriptjs) editorKeys.push('scriptjs'); if (mainpy) editorKeys.push('mainpy'); if (indexts) editorKeys.push('indexts'); + if (tsconfigjson) editorKeys.push('tsconfigjson'); const editorAndSplitterKeys = editorKeys.reduce((acc: string[] | [], key) => { if (acc.length === 0) { diff --git a/client/utils/__fixtures__/challenges.ts b/client/utils/__fixtures__/challenges.ts index 45ab1784330..44931007bc8 100644 --- a/client/utils/__fixtures__/challenges.ts +++ b/client/utils/__fixtures__/challenges.ts @@ -1,4 +1,4 @@ -import { ChallengeFile } from "../../src/redux/prop-types"; +import { ChallengeFile } from '../../src/redux/prop-types'; export const challengeFiles: ChallengeFile[] = [ { @@ -13,7 +13,7 @@ export const challengeFiles: ChallengeFile[] = [ tail: '', editableRegionBoundaries: [], usesMultifileEditor: true, - path: 'index.ts', + path: 'index.ts' }, { contents: 'some css', @@ -27,7 +27,7 @@ export const challengeFiles: ChallengeFile[] = [ tail: '', editableRegionBoundaries: [], usesMultifileEditor: true, - path: 'styles.css', + path: 'styles.css' }, { contents: 'some html', @@ -41,7 +41,7 @@ export const challengeFiles: ChallengeFile[] = [ tail: '', editableRegionBoundaries: [], usesMultifileEditor: true, - path: 'index.html', + path: 'index.html' }, { contents: 'some js', @@ -55,7 +55,7 @@ export const challengeFiles: ChallengeFile[] = [ tail: '', editableRegionBoundaries: [], usesMultifileEditor: true, - path: 'script.js', + path: 'script.js' }, { contents: 'some jsx', @@ -69,7 +69,7 @@ export const challengeFiles: ChallengeFile[] = [ tail: '', editableRegionBoundaries: [], usesMultifileEditor: true, - path: 'index.jsx', + path: 'index.jsx' }, { contents: 'some tsx', @@ -83,6 +83,20 @@ export const challengeFiles: ChallengeFile[] = [ tail: '', editableRegionBoundaries: [], usesMultifileEditor: true, - path: 'index.tsx', + path: 'index.tsx' + }, + { + contents: '{\n "compilerOptions": {}\n}', + error: null, + ext: 'json', + head: '', + history: ['tsconfig.json'], + fileKey: 'tsconfigjson', + name: 'tsconfig', + seed: '{\n "compilerOptions": {}\n}', + tail: '', + editableRegionBoundaries: [], + usesMultifileEditor: true, + path: 'tsconfig.json' } -] +]; diff --git a/client/utils/sort-challengefiles.test.ts b/client/utils/sort-challengefiles.test.ts index f05e86d2bfd..52e23fde5d6 100644 --- a/client/utils/sort-challengefiles.test.ts +++ b/client/utils/sort-challengefiles.test.ts @@ -15,7 +15,7 @@ describe('sort-files', () => { expect(sorted.length).toEqual(expected.length); }); - it('should sort the objects into jsx, tsx, html, css, js, ts order', () => { + it('should sort the objects into jsx, tsx, html, css, js, ts, tsconfig order', () => { const sorted = sortChallengeFiles(challengeFiles); const sortedKeys = sorted.map(({ fileKey }) => fileKey); const expected = [ @@ -24,7 +24,8 @@ describe('sort-files', () => { 'indexhtml', 'stylescss', 'scriptjs', - 'indexts' + 'indexts', + 'tsconfigjson' ]; expect(sortedKeys).toStrictEqual(expected); }); diff --git a/client/utils/sort-challengefiles.ts b/client/utils/sort-challengefiles.ts index 4db2f4d52a9..b1bdb1b18f3 100644 --- a/client/utils/sort-challengefiles.ts +++ b/client/utils/sort-challengefiles.ts @@ -14,6 +14,8 @@ export function sortChallengeFiles( if (b.fileKey === 'scriptjs') return 1; if (a.fileKey === 'indexts') return -1; if (b.fileKey === 'indexts') return 1; + if (a.fileKey === 'tsconfigjson') return -1; + if (b.fileKey === 'tsconfigjson') return 1; return 0; }); } diff --git a/packages/challenge-builder/src/build.test.ts b/packages/challenge-builder/src/build.test.ts new file mode 100644 index 00000000000..1f09b32737c --- /dev/null +++ b/packages/challenge-builder/src/build.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getTSConfig } from './build'; +import { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl'; + +describe('getTSConfig', () => { + it("should return the tsconfig file's contents if it exists", () => { + const compileOptions = 'any string is valid here'; + const challengeFiles = [ + { name: 'index', ext: 'ts' }, + { name: 'tsconfig', ext: 'json', contents: compileOptions } + ] as ChallengeFile[]; + + expect(getTSConfig(challengeFiles)).toEqual(compileOptions); + }); + + it('should return null if there is no tsconfig file', () => { + const challengeFiles = [ + { name: 'index', ext: 'ts' }, + { name: 'app', ext: 'ts' } + ] as ChallengeFile[]; + + expect(getTSConfig(challengeFiles)).toBeNull(); + }); + + it('should throw an error if there are multiple tsconfig.json files', () => { + const challengeFiles = [ + { name: 'index', ext: 'ts' }, + { name: 'tsconfig', ext: 'json' }, + { name: 'tsconfig', ext: 'json' } + ] as ChallengeFile[]; + + expect(() => getTSConfig(challengeFiles)).toThrow( + 'TypeScript challenge must include only one tsconfig.json file' + ); + }); +}); diff --git a/packages/challenge-builder/src/build.ts b/packages/challenge-builder/src/build.ts index 90560260941..e0012971b75 100644 --- a/packages/challenge-builder/src/build.ts +++ b/packages/challenge-builder/src/build.ts @@ -8,6 +8,7 @@ import { getPythonTransformers, getMultifileJSXTransformers } from './transformers.js'; +import { setupTSCompiler } from './typescript-worker-handler.js'; interface Source { index: string; @@ -165,6 +166,39 @@ type BuildResult = { error?: unknown; }; +function hasTS(challengeFiles: ChallengeFile[]) { + return challengeFiles.some( + challengeFile => challengeFile.ext === 'ts' || challengeFile.ext === 'tsx' + ); +} + +const isTSConfig = (f: { name: string; ext: string }) => + f.name === 'tsconfig' && f.ext === 'json'; + +export function getTSConfig(challengeFiles: ChallengeFile[]) { + const tsConfigFiles = challengeFiles.filter(isTSConfig); + + if (tsConfigFiles.length > 1) { + throw new Error( + 'TypeScript challenge must include only one tsconfig.json file' + ); + } + + return tsConfigFiles.length === 1 ? tsConfigFiles[0].contents : null; +} + +async function configureTSCompiler(challengeFiles: ChallengeFile[]) { + if (hasTS(challengeFiles)) { + const tsConfig = getTSConfig(challengeFiles); + + if (tsConfig) { + await setupTSCompiler(tsConfig); + } else { + await setupTSCompiler(); + } + } +} + // TODO: All the buildXChallenge files have a similar structure, so make that // abstraction (function, class, whatever) and then create the various functions // out of it. @@ -182,12 +216,10 @@ async function buildDOMChallenge( const hasJsx = challengeFiles.some( challengeFile => challengeFile.ext === 'jsx' || challengeFile.ext === 'tsx' ); - const isMultifile = challengeFiles.length > 1; - - const requiresReact16 = required.some(({ src }) => - src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.') - ); + await configureTSCompiler(challengeFiles); + const sourceFiles = challengeFiles.filter(file => !isTSConfig(file)); + const isMultifile = sourceFiles.length > 1; // I'm reasonably sure this is fine, but we need to migrate transformers to // TypeScript to be sure. const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx @@ -195,7 +227,7 @@ async function buildDOMChallenge( : getTransformers(options)) as unknown as ApplyFunctionProps[]; const pipeLine = composeFunctions(...transformers); - const finalFiles = await Promise.all(challengeFiles.map(pipeLine)); + const finalFiles = await Promise.all(sourceFiles.map(pipeLine)); const error = finalFiles.find(({ error }) => error)?.error; const contents = (await embedFilesInHtml(finalFiles)) as string; @@ -209,6 +241,10 @@ async function buildDOMChallenge( contents }; + const requiresReact16 = required.some(({ src }) => + src?.includes('https://cdnjs.cloudflare.com/ajax/libs/react/16.') + ); + return { challengeType, build: concatHtml(toBuild), @@ -230,7 +266,9 @@ async function buildJSChallenge( ...(getTransformers(options) as unknown as ApplyFunctionProps[]) ); - const finalFiles = await Promise.all(challengeFiles?.map(pipeLine)); + await configureTSCompiler(challengeFiles); + const sourceFiles = challengeFiles.filter(file => !isTSConfig(file)); + const finalFiles = await Promise.all(sourceFiles?.map(pipeLine)); const error = finalFiles.find(({ error }) => error)?.error; const toBuild = error ? [] : finalFiles; diff --git a/packages/challenge-builder/src/transformers.js b/packages/challenge-builder/src/transformers.js index 9299600b43f..d2fae43c8e0 100644 --- a/packages/challenge-builder/src/transformers.js +++ b/packages/challenge-builder/src/transformers.js @@ -18,10 +18,7 @@ import { import { version } from '@freecodecamp/browser-scripts/package.json'; import { WorkerExecutor } from './worker-executor'; -import { - compileTypeScriptCode, - setupTSCompiler -} from './typescript-worker-handler'; +import { compileTypeScriptCode } from './typescript-worker-handler'; const protectTimeout = 100; const testProtectTimeout = 1500; @@ -148,7 +145,6 @@ const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => { const getTSTranspiler = loopProtectOptions => async challengeFile => { await loadBabel(); - await setupTSCompiler(); const babelOptions = getBabelOptions(presetsJS, loopProtectOptions); return flow( partial(transformHeadTailAndContents, compileTypeScriptCode), @@ -159,7 +155,6 @@ const getTSTranspiler = loopProtectOptions => async challengeFile => { const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => { await loadBabel(); await loadPresetReact(); - await setupTSCompiler(); const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions); const babelOptions = { ...baseOptions, @@ -379,7 +374,18 @@ function challengeFilesToObject(challengeFiles) { const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs'); const indexTs = challengeFiles.find(file => file.fileKey === 'indexts'); const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx'); - return { indexHtml, indexJsx, stylesCss, scriptJs, indexTs, indexTsx }; + const tsconfigJson = challengeFiles.find( + file => file.fileKey === 'tsconfigjson' + ); + return { + indexHtml, + indexJsx, + stylesCss, + scriptJs, + indexTs, + indexTsx, + tsconfigJson + }; } const parseAndTransform = async function (transform, contents) { diff --git a/packages/challenge-builder/src/typescript-worker-handler.ts b/packages/challenge-builder/src/typescript-worker-handler.ts index a255a085082..805992ae402 100644 --- a/packages/challenge-builder/src/typescript-worker-handler.ts +++ b/packages/challenge-builder/src/typescript-worker-handler.ts @@ -31,12 +31,10 @@ export function compileTypeScriptCode(code: string): Promise { }); } -export function setupTSCompiler( - compilerOptions?: Record -): Promise { +export function setupTSCompiler(tsconfig?: string): Promise { return awaitResponse({ messenger: getTypeScriptWorker(), - message: { type: 'setup', ...(compilerOptions && { compilerOptions }) }, + message: { type: 'setup', ...(tsconfig && { tsconfig }) }, onMessage: (data, onSuccess) => { if (data.type === 'ready') { onSuccess(true); diff --git a/packages/shared/src/utils/polyvinyl.ts b/packages/shared/src/utils/polyvinyl.ts index f1f76c4a02f..1e68cc78890 100644 --- a/packages/shared/src/utils/polyvinyl.ts +++ b/packages/shared/src/utils/polyvinyl.ts @@ -1,4 +1,4 @@ -const exts = ['js', 'html', 'css', 'jsx', 'ts', 'tsx', 'py'] as const; +const exts = ['js', 'html', 'css', 'jsx', 'ts', 'tsx', 'py', 'json'] as const; export type Ext = (typeof exts)[number]; export interface IncompleteChallengeFile { diff --git a/tools/challenge-parser/parser/__fixtures__/simple.md b/tools/challenge-parser/parser/__fixtures__/simple.md index 59717868f0a..09f714c2e6d 100644 --- a/tools/challenge-parser/parser/__fixtures__/simple.md +++ b/tools/challenge-parser/parser/__fixtures__/simple.md @@ -58,6 +58,14 @@ body { var x = 'y'; ``` +```json +{ + "compilerOptions": { + "target": "ES2020" + } +} +``` + # --solutions-- diff --git a/tools/challenge-parser/parser/__snapshots__/index.acceptance.test.js.snap b/tools/challenge-parser/parser/__snapshots__/index.acceptance.test.js.snap index c35f5e2ccd1..01164d17e96 100644 --- a/tools/challenge-parser/parser/__snapshots__/index.acceptance.test.js.snap +++ b/tools/challenge-parser/parser/__snapshots__/index.acceptance.test.js.snap @@ -374,6 +374,19 @@ exports[`challenge parser > should parse a simple md file 1`] = ` "name": "script", "tail": "", }, + { + "contents": "{ + "compilerOptions": { + "target": "ES2020" + } +}", + "editableRegionBoundaries": [], + "ext": "json", + "head": "", + "id": "", + "name": "tsconfig", + "tail": "", + }, ], "description": "

Paragraph 1

diff --git a/tools/challenge-parser/parser/plugins/__snapshots__/add-seed.test.js.snap b/tools/challenge-parser/parser/plugins/__snapshots__/add-seed.test.js.snap index 78738ba3f45..5bab92e85ac 100644 --- a/tools/challenge-parser/parser/plugins/__snapshots__/add-seed.test.js.snap +++ b/tools/challenge-parser/parser/plugins/__snapshots__/add-seed.test.js.snap @@ -35,6 +35,19 @@ exports[`add-seed plugin > should have an output to match the snapshot 1`] = ` "name": "script", "tail": "", }, + { + "contents": "{ + "compilerOptions": { + "target": "ES2020" + } +}", + "editableRegionBoundaries": [], + "ext": "json", + "head": "", + "id": "", + "name": "tsconfig", + "tail": "", + }, ], } `; diff --git a/tools/challenge-parser/parser/plugins/add-seed.test.js b/tools/challenge-parser/parser/plugins/add-seed.test.js index 9e320e0f1a4..a24554e59b8 100644 --- a/tools/challenge-parser/parser/plugins/add-seed.test.js +++ b/tools/challenge-parser/parser/plugins/add-seed.test.js @@ -272,6 +272,19 @@ const Button = () => { };`); }); + it('handles json', () => { + expect.assertions(1); + plugin(simpleAST, file); + const { + data: { challengeFiles } + } = file; + const tsconfigjsonc = challengeFiles.find(x => x.ext === 'json'); + + expect(tsconfigjsonc.contents).toBe( + `{\n "compilerOptions": {\n "target": "ES2020"\n }\n}` + ); + }); + it('should throw an error if a seed has no contents', () => { expect.assertions(1); expect(() => plugin(withEmptyContentsAST, file)).toThrow( diff --git a/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js b/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js index 86c2b9a3d10..aaf0b6f55ad 100644 --- a/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js +++ b/tools/challenge-parser/parser/plugins/utils/get-file-visitor.js @@ -8,7 +8,16 @@ const keyToSection = { head: 'before-user-code', tail: 'after-user-code' }; -const supportedLanguages = ['js', 'css', 'html', 'jsx', 'py', 'ts', 'tsx']; +const supportedLanguages = [ + 'js', + 'css', + 'html', + 'jsx', + 'py', + 'ts', + 'tsx', + 'json' +]; const longToShortLanguages = { javascript: 'js', typescript: 'ts', @@ -30,7 +39,8 @@ function getFilenames(lang) { const langToFilename = { js: 'script', css: 'styles', - py: 'main' + py: 'main', + json: 'tsconfig' }; return langToFilename[lang] ?? 'index'; } diff --git a/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts b/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts index 1f457295667..a84b3879482 100644 --- a/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts +++ b/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts @@ -16,13 +16,25 @@ export class Compiler { this.tsvfs = tsvfs; } - async setup(opts?: { useNodeModules?: boolean; compilerOptions?: unknown }) { + async setup(opts?: { useNodeModules?: boolean; tsconfig?: string }) { const ts = this.ts; const tsvfs = this.tsvfs; - const parsedOptions = ts.convertCompilerOptionsFromJson( - opts?.compilerOptions ?? {}, - '/' + // This just parses the JSON, it doesn't do any validation. + const parsedOptions = opts?.tsconfig + ? (ts.parseConfigFileTextToJson('', opts.tsconfig).config as { + compilerOptions?: unknown; + }) + : undefined; + + // For now we're only interested in the compilerOptions, so that's all we're + // extracting and validating. For everything else, we could + // parseJsonConfigFileContent and create a host using createSystem and + // fsMap, but that needs compilerOptions... This is a bit of a chicken and + // egg problem, which we don't need to solve yet. + const validatedOptions = ts.convertCompilerOptionsFromJson( + parsedOptions?.compilerOptions ?? {}, + './' ); const compilerOptions: CompilerOptions = { @@ -34,7 +46,7 @@ export class Compiler { // 3.8.0-rc." jsx: ts.JsxEmit.Preserve, // Babel will handle JSX, allowUmdGlobalAccess: true, // Necessary because React is loaded via a UMD script. - ...parsedOptions.options + ...validatedOptions.options }; const fsMap = opts?.useNodeModules diff --git a/tools/client-plugins/browser-scripts/typescript-worker.ts b/tools/client-plugins/browser-scripts/typescript-worker.ts index bb5011bdf8f..58ef6d5a8d6 100644 --- a/tools/client-plugins/browser-scripts/typescript-worker.ts +++ b/tools/client-plugins/browser-scripts/typescript-worker.ts @@ -1,4 +1,3 @@ -import type { CompilerOptions } from 'typescript'; import { Compiler } from './modules/typescript-compiler'; // Most of the ts types are only a guideline. This is because we're not bundling @@ -27,7 +26,7 @@ interface TSCompiledMessage { interface SetupEvent extends MessageEvent { data: { type: 'setup'; - compilerOptions?: CompilerOptions; + tsconfig?: string; }; } @@ -83,7 +82,7 @@ function handleCancelRequest({ value }: { value: number }) { async function handleSetupRequest(data: SetupEvent['data'], port: MessagePort) { await compiler.setup({ - compilerOptions: data.compilerOptions + tsconfig: data.tsconfig }); // We freeze this to prevent learners from getting the worker into a weird // state.