Files
freeCodeCamp/packages/challenge-builder/src/transformers.js

418 lines
13 KiB
JavaScript

import protect from '@freecodecamp/loop-protect';
import {
cond,
flow,
identity,
matchesProperty,
overSome,
partial,
stubTrue
} from 'lodash-es';
import {
transformContents,
createSource
} from '@freecodecamp/shared/utils/polyvinyl';
import { version } from '@freecodecamp/browser-scripts/package.json';
import { WorkerExecutor } from './worker-executor';
import { compileTypeScriptCode } from './typescript-worker-handler';
const protectTimeout = 100;
const testProtectTimeout = 1500;
const loopsPerTimeoutCheck = 100;
const testLoopsPerTimeoutCheck = 2000;
const MODULE_TRANSFORM_PLUGIN = 'transform-modules-umd';
function loopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may fail if this is not changed.`
);
}
function testLoopProtectCB(line) {
console.log(
`Potential infinite loop detected on line ${line}. Tests may be failing because of this.`
);
}
// hold Babel and presets so we don't try to import them multiple times
let Babel;
let presetEnv, presetReact;
let presetsJS, presetsJSX;
async function loadBabel() {
if (Babel) return;
Babel = await import(
/* webpackChunkName: "@babel/standalone" */ '@babel/standalone'
);
Babel.registerPlugin(
'loopProtection',
protect(protectTimeout, loopProtectCB, loopsPerTimeoutCheck)
);
Babel.registerPlugin(
'testLoopProtection',
protect(testProtectTimeout, testLoopProtectCB, testLoopsPerTimeoutCheck)
);
}
async function loadPresetEnv() {
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJS = {
presets: [[presetEnv, { exclude: ['transform-spread'] }]]
};
}
async function loadPresetReact() {
if (!presetReact)
presetReact = await import(
/* webpackChunkName: "@babel/preset-react" */ '@babel/preset-react'
);
if (!presetEnv)
presetEnv = await import(
/* webpackChunkName: "@babel/preset-env" */ '@babel/preset-env'
);
presetsJSX = {
presets: [[presetEnv, { exclude: ['transform-spread'] }], presetReact]
};
}
const babelTransformCode = options => code =>
Babel.transform(code, options).code;
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$TSX = overSome(
testHTML,
testJS,
testJSX,
testTypeScript,
testTSX
);
const replaceNBSP = cond([
[
testHTML$JS$JSX$TS$TSX,
partial(transformContents, contents => contents.replace(NBSPReg, ' '))
],
[stubTrue, identity]
]);
const getJSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetEnv();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getJSXTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const babelOptions = getBabelOptions(presetsJSX, loopProtectOptions);
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getJSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return transformContents(babelTransformCode(babelOptions), challengeFile);
};
const getTSTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
const babelOptions = getBabelOptions(presetsJS, loopProtectOptions);
return flow(
partial(transformContents, compileTypeScriptCode),
partial(transformContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const getTSXModuleTranspiler = loopProtectOptions => async challengeFile => {
await loadBabel();
await loadPresetReact();
const baseOptions = getBabelOptions(presetsJSX, loopProtectOptions);
const babelOptions = {
...baseOptions,
plugins: [...baseOptions.plugins, MODULE_TRANSFORM_PLUGIN],
moduleId: 'index' // TODO: this should be dynamic
};
return flow(
partial(transformContents, compileTypeScriptCode),
partial(transformContents, babelTransformCode(babelOptions))
)(challengeFile);
};
const createTranspiler = loopProtectOptions => {
return cond([
[testJS, getJSTranspiler(loopProtectOptions)],
[testJSX, getJSXTranspiler(loopProtectOptions)],
[testTypeScript, getTSTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: false })],
[stubTrue, identity]
]);
};
const createModuleTransformer = loopProtectOptions => {
return cond([
[testJSX, getJSXModuleTranspiler(loopProtectOptions)],
[testTSX, getTSXModuleTranspiler(loopProtectOptions)],
[testHTML, getHtmlTranspiler({ useModules: true })],
[stubTrue, identity]
]);
};
function getBabelOptions(
presets,
{ preview, disableLoopProtectTests, disableLoopProtectPreview } = {
preview: false,
disableLoopProtectTests: false,
disableLoopProtectPreview: false
}
) {
// we protect the preview unless specifically disabled, since it evaluates as
// the user types and they may briefly have infinite looping code accidentally
if (preview && !disableLoopProtectPreview)
return { ...presets, plugins: ['loopProtection'] };
if (!disableLoopProtectTests)
return { ...presets, plugins: ['testLoopProtection'] };
return presets;
}
const sassWorkerExecutor = new WorkerExecutor(
`workers/${version}/sass-compile`
);
async function transformSASS(documentElement) {
// we only teach scss syntax, not sass. Also the compiler does not seem to be
// able to deal with sass.
const styleTags = documentElement.querySelectorAll(
'style[type~="text/scss"]'
);
await Promise.all(
[].map.call(styleTags, async style => {
style.type = 'text/css';
style.innerHTML = await sassWorkerExecutor.execute(style.innerHTML, 5000)
.done;
})
);
}
async function transformScript(documentElement, { useModules }) {
await loadBabel();
await loadPresetEnv();
await loadPresetReact();
const scriptTags = documentElement.querySelectorAll('script');
scriptTags.forEach(script => {
const isBabel = script.type === 'text/babel';
const hasSource = !!script.src;
// TODO: make the use of JSX conditional on more than just the script type.
// It should only be used for React challenges since it would be confusing
// for learners to see the results of a transformation they didn't ask for.
const baseOptions = isBabel ? presetsJSX : presetsJS;
const options = {
...baseOptions,
...(useModules && { plugins: [MODULE_TRANSFORM_PLUGIN] })
};
// The type has to be removed, otherwise the browser will ignore the script.
// However, if we're importing modules, the type will be removed when the
// scripts are embedded in the HTML.
if (isBabel && !useModules) script.removeAttribute('type');
// We could use babel standalone to transform inline code in the preview,
// but that generates a warning that's shown to learner. By removing the
// type attribute and transforming the code we can avoid that warning.
if (isBabel && !hasSource) {
script.removeAttribute('type');
script.setAttribute('data-type', 'text/babel');
}
// Skip unnecessary transformations
script.innerHTML = script.innerHTML
? babelTransformCode(options)(script.innerHTML)
: '';
});
}
const deferScript = scriptCode => {
// Mimic the behavior of a defer script by waiting until the DOM is loaded
// before executing the script.
return `
(() => {
const run = (() => {
if (document.readyState === "interactive") {
${scriptCode}
}
});
document.addEventListener('readystatechange', run, { once: true });
})();
`;
};
export const embedScript = (script, source, contents) => {
const code = contents ?? '';
script.innerHTML = script.hasAttribute('defer') ? deferScript(code) : code;
script.removeAttribute('src');
script.setAttribute('data-src', source);
};
// 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, indexTsx } =
challengeFilesToObject(challengeFiles);
const embedStylesAndScript = contentDocument => {
const documentElement = contentDocument.documentElement;
const link =
documentElement.querySelector('link[href="styles.css"]') ??
documentElement.querySelector('link[href="./styles.css"]');
const script =
documentElement.querySelector('script[src="script.js"]') ??
documentElement.querySelector('script[src="./script.js"]');
const tsScript =
documentElement.querySelector('script[src="index.ts"]') ??
documentElement.querySelector('script[src="./index.ts"]');
const jsxScript =
documentElement.querySelector(
`script[data-plugins="${MODULE_TRANSFORM_PLUGIN}"][type="text/babel"][src="index.jsx"]`
) ??
documentElement.querySelector(
`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');
style.innerHTML = stylesCss?.contents;
link.parentNode.appendChild(style);
link.removeAttribute('href');
link.dataset.href = 'styles.css';
}
if (script) {
embedScript(script, 'script.js', scriptJs?.contents);
}
if (tsScript) {
embedScript(tsScript, 'index.ts', indexTs?.contents);
}
if (jsxScript) {
embedScript(jsxScript, 'index.jsx', indexJsx?.contents);
jsxScript.removeAttribute('type');
jsxScript.setAttribute('data-type', 'text/babel');
}
if (tsxScript) {
embedScript(tsxScript, 'index.tsx', indexTsx?.contents);
tsxScript.removeAttribute('type');
tsxScript.setAttribute('data-type', 'text/babel');
}
return documentElement.innerHTML;
};
if (indexHtml) {
const contents = await parseAndTransform(
embedStylesAndScript,
indexHtml.contents
);
return contents;
} else if (indexJsx) {
return `<script>${indexJsx.contents}</script>`;
} else if (scriptJs) {
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(x) or js(x) file found');
}
};
function challengeFilesToObject(challengeFiles) {
const indexHtml = challengeFiles.find(file => file.fileKey === 'indexhtml');
const indexJsx = challengeFiles.find(file => file.fileKey === 'indexjsx');
const stylesCss = challengeFiles.find(file => file.fileKey === 'stylescss');
const scriptJs = challengeFiles.find(file => file.fileKey === 'scriptjs');
const indexTs = challengeFiles.find(file => file.fileKey === 'indexts');
const indexTsx = challengeFiles.find(file => file.fileKey === 'indextsx');
const tsconfigJson = challengeFiles.find(
file => file.fileKey === 'tsconfigjson'
);
return {
indexHtml,
indexJsx,
stylesCss,
scriptJs,
indexTs,
indexTsx,
tsconfigJson
};
}
const parseAndTransform = async function (transform, contents) {
const parser = new DOMParser();
const newDoc = parser.parseFromString(contents, 'text/html');
return await transform(newDoc);
};
const getHtmlTranspiler = scriptOptions =>
async function (file) {
const transform = async contentDocument => {
const documentElement = contentDocument.documentElement;
await Promise.all([
transformSASS(documentElement),
transformScript(documentElement, scriptOptions)
]);
return documentElement.innerHTML;
};
const contents = await parseAndTransform(transform, file.contents);
return transformContents(() => contents, file);
};
export const getTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createTranspiler(loopProtectOptions)
];
export const getMultifileJSXTransformers = loopProtectOptions => [
createSource,
replaceNBSP,
createModuleTransformer(loopProtectOptions)
];
export const getPythonTransformers = () => [createSource, replaceNBSP];