refactor: add prefixDoctype to challenge-builder + DRY build functions (#65523)

This commit is contained in:
Oliver Eyton-Williams 2026-01-27 13:09:58 +01:00 committed by GitHub
parent 528c2afe14
commit e2ebf5e09d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 25 additions and 224 deletions

View File

@ -15,7 +15,10 @@ import {
} from 'redux-saga/effects';
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
import {
buildChallenge,
canBuildChallenge
} from '@freecodecamp/challenge-builder/build';
import { createFlashMessage } from '../../../components/Flash/redux';
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
@ -27,7 +30,6 @@ import {
} from '../../../utils/challenge-request-helpers';
import { playTone } from '../../../utils/tone';
import {
canBuildChallenge,
challengeHasPreview,
getTestRunner,
isJavaScriptChallenge,

View File

@ -1,13 +1,6 @@
import { challengeTypes } from '@freecodecamp/shared/config/challenge-types';
import type { ChallengeFile } from '@freecodecamp/shared/utils/polyvinyl';
import {
getTransformers,
embedFilesInHtml,
getPythonTransformers,
getMultifileJSXTransformers
} from '@freecodecamp/challenge-builder/transformers';
import { concatHtml } from '@freecodecamp/challenge-builder/builders';
import { runnerTypes } from '@freecodecamp/challenge-builder/build';
import {
@ -16,7 +9,6 @@ import {
createProjectPreviewFramer,
ProxyLogger,
Context,
Source,
prepTestRunner
} from './frame';
@ -28,77 +20,6 @@ interface BuildChallengeData extends Context {
url: string;
}
interface BuildOptions {
preview: boolean;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
usesTestRunner?: boolean;
}
type ApplyFunctionProps = (
file: ChallengeFile
) => Promise<ChallengeFile> | ChallengeFile;
const applyFunction =
(fn: ApplyFunctionProps) => async (file: ChallengeFile) => {
try {
if (file.error) {
return file;
}
const newFile = await fn.call(this, file);
if (typeof newFile !== 'undefined') {
return newFile;
}
return file;
} catch (error) {
return { ...file, error };
}
};
const composeFunctions = (...fns: ApplyFunctionProps[]) =>
fns.map(applyFunction).reduce((f, g) => x => f(x).then(g));
function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
// TODO: rename sources.index to sources.contents.
const source: Source | undefined = challengeFiles?.reduce(
(sources, challengeFile) => {
sources.index += challengeFile.source || '';
sources.contents = sources.index;
sources.editableContents += challengeFile.editableContents || '';
return sources;
},
{
index: '',
editableContents: ''
} as Source
);
return source;
}
export const buildFunctions = {
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.html]: buildDOMChallenge,
[challengeTypes.modern]: buildDOMChallenge,
[challengeTypes.backend]: buildBackendChallenge,
[challengeTypes.backEndProject]: buildBackendChallenge,
[challengeTypes.pythonProject]: buildBackendChallenge,
[challengeTypes.multifileCertProject]: buildDOMChallenge,
[challengeTypes.colab]: buildBackendChallenge,
[challengeTypes.python]: buildPythonChallenge,
[challengeTypes.multifilePythonCertProject]: buildPythonChallenge,
[challengeTypes.lab]: buildDOMChallenge,
[challengeTypes.jsLab]: buildJSChallenge,
[challengeTypes.pyLab]: buildPythonChallenge,
[challengeTypes.dailyChallengeJs]: buildJSChallenge,
[challengeTypes.dailyChallengePy]: buildPythonChallenge
};
export function canBuildChallenge(challengeData: BuildChallengeData): boolean {
const { challengeType } = challengeData;
return Object.prototype.hasOwnProperty.call(buildFunctions, challengeType);
}
export async function getTestRunner(buildData: BuildChallengeData) {
const { challengeType } = buildData;
// TODO: Fully type BuildChallengeData
@ -115,130 +36,6 @@ export async function getTestRunner(buildData: BuildChallengeData) {
runTestsInTestFrame(testStrings, testTimeout, type);
}
type BuildResult = {
challengeType: number;
build?: string;
sources: Source | undefined;
loadEnzyme?: boolean;
error?: unknown;
};
// 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.
export async function buildDOMChallenge(
{
challengeFiles,
required = [],
template = '',
challengeType
}: BuildChallengeData,
options?: BuildOptions
): Promise<BuildResult> {
// TODO: make this required in the schema.
if (!challengeFiles) throw Error('No challenge files provided');
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.')
);
// I'm reasonably sure this is fine, but we need to migrate transformers to
// TypeScript to be sure.
const transformers: ApplyFunctionProps[] = (isMultifile && hasJsx
? getMultifileJSXTransformers(options)
: getTransformers(options)) as unknown as ApplyFunctionProps[];
const pipeLine = composeFunctions(...transformers);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const contents = (await embedFilesInHtml(finalFiles)) as string;
// if there is an error, we just build the test runner so that it can be
// used to run tests against the code without actually running the code.
const toBuild = error
? {}
: {
required,
template,
contents
};
return {
challengeType,
build: concatHtml(toBuild),
sources: buildSourceMap(finalFiles),
loadEnzyme: requiresReact16,
error
};
}
export async function buildJSChallenge(
{
challengeFiles,
challengeType
}: { challengeFiles?: ChallengeFile[]; challengeType: number },
options: BuildOptions
): Promise<BuildResult> {
if (!challengeFiles) throw Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getTransformers(options) as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles?.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const toBuild = error ? [] : finalFiles;
return {
challengeType,
build: toBuild
.reduce(
(body, challengeFile) => [
...body,
challengeFile.head,
challengeFile.contents,
challengeFile.tail
],
[] as string[]
)
.join('\n'),
sources: buildSourceMap(finalFiles),
error
};
}
function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
return {
challengeType,
build: '',
sources: { contents: url }
};
}
export async function buildPythonChallenge({
challengeFiles,
challengeType
}: BuildChallengeData): Promise<BuildResult> {
if (!challengeFiles) throw new Error('No challenge files provided');
const pipeLine = composeFunctions(
...(getPythonTransformers() as unknown as ApplyFunctionProps[])
);
const finalFiles = await Promise.all(challengeFiles.map(pipeLine));
const error = finalFiles.find(({ error }) => error)?.error;
const sources = buildSourceMap(finalFiles);
return {
challengeType,
sources,
build: sources?.contents,
error
};
}
export function updatePreview(
buildData: BuildChallengeData,
document: Document,

View File

@ -1,6 +1,8 @@
import { flow } from 'lodash-es';
import i18next, { type i18n } from 'i18next';
import { prefixDoctype } from '@freecodecamp/challenge-builder/build';
import {
version as _helperVersion,
type FCCTestRunner
@ -24,7 +26,7 @@ declare global {
const utilsFormat: <T>(x: T) => string = format;
export interface Source {
interface Source {
index: string;
contents?: string;
editableContents: string;
@ -410,19 +412,6 @@ const waitForFrame = (frameContext: Context) => {
});
};
export const prefixDoctype = ({
build,
sources
}: {
build: string;
sources: Source;
}) => {
// DOCTYPE should be the first thing written to the frame, so if the user code
// includes a DOCTYPE declaration, we need to find it and write it first.
const doctype = sources.contents?.match(/^<!DOCTYPE html>/i)?.[0] || '';
return doctype + build;
};
const createContent = (
id: string,
{ build, sources }: { build: string; sources: Source; hooks?: Hooks }

View File

@ -8,7 +8,7 @@ import {
hasNoSolution
} from '@freecodecamp/shared/config/challenge-types';
import { getLines } from '@freecodecamp/shared/utils/get-lines';
import { prefixDoctype } from '../../../client/src/templates/Challenges/utils/frame';
import { prefixDoctype } from '@freecodecamp/challenge-builder/build';
import { getChallengesForLang } from '../get-challenges.js';
import { challengeSchemaValidator } from '../../schema/challenge-schema.js';

View File

@ -70,7 +70,7 @@ function buildSourceMap(challengeFiles: ChallengeFile[]): Source | undefined {
return source;
}
export const buildFunctions = {
const buildFunctions = {
[challengeTypes.js]: buildJSChallenge,
[challengeTypes.jsProject]: buildJSChallenge,
[challengeTypes.html]: buildDOMChallenge,
@ -106,6 +106,19 @@ export async function buildChallenge(
throw new Error(`Cannot build challenge of type ${challengeType}`);
}
export const prefixDoctype = ({
build,
sources
}: {
build: string;
sources: Source;
}) => {
// DOCTYPE should be the first thing written to the frame, so if the user code
// includes a DOCTYPE declaration, we need to find it and write it first.
const doctype = sources.contents?.match(/^<!DOCTYPE html>/i)?.[0] || '';
return doctype + build;
};
export const runnerTypes: Record<
(typeof challengeTypes)[keyof typeof challengeTypes],
'javascript' | 'dom' | 'python'
@ -155,7 +168,7 @@ type BuildResult = {
// 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.
export async function buildDOMChallenge(
async function buildDOMChallenge(
{
challengeFiles,
required = [],
@ -205,7 +218,7 @@ export async function buildDOMChallenge(
};
}
export async function buildJSChallenge(
async function buildJSChallenge(
{
challengeFiles,
challengeType
@ -248,7 +261,7 @@ function buildBackendChallenge({ url, challengeType }: BuildChallengeData) {
};
}
export async function buildPythonChallenge({
async function buildPythonChallenge({
challengeFiles,
challengeType
}: BuildChallengeData): Promise<BuildResult> {