441 lines
12 KiB
JavaScript
441 lines
12 KiB
JavaScript
![]() |
const { isEmpty, pick } = require('lodash');
|
||
|
const yaml = require('js-yaml');
|
||
|
const he = require('he');
|
||
|
const prettier = require('prettier');
|
||
|
|
||
|
const prettierOptions = prettier.resolveConfig.sync();
|
||
|
|
||
|
const {
|
||
|
getCodeToBackticksSync,
|
||
|
prettifySync
|
||
|
} = require('../../formatter/fcc-md-to-gfm/transformChallenges');
|
||
|
const { correctUrl } = require('../../formatter/fcc-md-to-gfm/insert-spaces');
|
||
|
|
||
|
const codeToBackticksSync = getCodeToBackticksSync(true);
|
||
|
const unified = require('unified');
|
||
|
|
||
|
const remarkParse = require('remark-parse');
|
||
|
const find = require('unist-util-find');
|
||
|
const remark2rehype = require('remark-rehype');
|
||
|
const html = require('rehype-stringify');
|
||
|
const raw = require('rehype-raw');
|
||
|
|
||
|
var parser = unified().use(remarkParse);
|
||
|
var mdToHTML = unified()
|
||
|
.use(remarkParse)
|
||
|
.use(remark2rehype, { allowDangerousHtml: true })
|
||
|
.use(raw)
|
||
|
.use(html, { allowDangerousCharacters: true, allowDangerousHtml: true })
|
||
|
.processSync;
|
||
|
|
||
|
function parseMd(text) {
|
||
|
return parser.parse(text);
|
||
|
}
|
||
|
|
||
|
// inspired by wrapRecursive, but takes in text and outputs text.
|
||
|
function wrapUrls(rawText) {
|
||
|
const mdNode = parseMd(rawText);
|
||
|
|
||
|
const link = find(mdNode, { type: 'link' });
|
||
|
|
||
|
if (link) {
|
||
|
const url = correctUrl(link.url);
|
||
|
const pos = rawText.indexOf(url);
|
||
|
const head = rawText.slice(0, pos);
|
||
|
const tail = rawText.slice(pos + url.length);
|
||
|
const newText = head + '`' + url + '`' + wrapUrls(tail);
|
||
|
return newText;
|
||
|
} else {
|
||
|
return rawText;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const frontmatterProperties = [
|
||
|
'id',
|
||
|
'title',
|
||
|
'challengeType',
|
||
|
'videoId',
|
||
|
'videoUrl',
|
||
|
'forumTopicId',
|
||
|
'isPrivate',
|
||
|
'required',
|
||
|
'helpCategory'
|
||
|
];
|
||
|
|
||
|
const otherProperties = [
|
||
|
'description',
|
||
|
'instructions',
|
||
|
'tests',
|
||
|
'solutions',
|
||
|
'files',
|
||
|
'question'
|
||
|
];
|
||
|
|
||
|
function createFrontmatter(data) {
|
||
|
Object.keys(data).forEach(key => {
|
||
|
if (!frontmatterProperties.includes(key) && !otherProperties.includes(key))
|
||
|
throw Error(`Unknown property '${key}'`);
|
||
|
});
|
||
|
|
||
|
// TODO: sort the keys? It doesn't matter from a machine perspective, but
|
||
|
// it does from human-readability one. We could get lucky and have the order
|
||
|
// be preserved accidentally.
|
||
|
const frontData = pick(data, frontmatterProperties);
|
||
|
const frontYAML = yaml.dump(frontData);
|
||
|
|
||
|
return `---
|
||
|
${frontYAML}---
|
||
|
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
// TODO: handle certs elsewhere (ideally don't try to create mdx versions)
|
||
|
function createHints({ tests, title }) {
|
||
|
if (!tests) return '';
|
||
|
const strTests = tests
|
||
|
.map(
|
||
|
({ text, testString }) => `${hintToMd(text, title)}
|
||
|
|
||
|
${'```js'}
|
||
|
${
|
||
|
typeof testString === 'string'
|
||
|
? prettier
|
||
|
.format(testString, { ...prettierOptions, parser: 'babel' })
|
||
|
.trim()
|
||
|
: ''
|
||
|
}
|
||
|
${'```'}
|
||
|
`
|
||
|
)
|
||
|
.join('\n');
|
||
|
return `# --hints--
|
||
|
|
||
|
${strTests}
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
function validateHints({ tests, question, title }) {
|
||
|
if (tests) {
|
||
|
tests.forEach(({ text }) => {
|
||
|
validateAndLog(text, title, false);
|
||
|
});
|
||
|
}
|
||
|
if (question && question.text) {
|
||
|
validateAndLog(question.text, title, false);
|
||
|
}
|
||
|
if (question && question.answers) {
|
||
|
question.answers.forEach(text => {
|
||
|
validateAndLog(text, title, false);
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function validateAndLog(text, title, log = true) {
|
||
|
const { valid, parsed, parsedSimplified, finalHint } = validateText(text);
|
||
|
if (!valid) {
|
||
|
if (log) {
|
||
|
console.log('original'.padEnd(8, ' '), text);
|
||
|
console.log('parsed'.padEnd(8, ' '), parsed);
|
||
|
console.log('finalP'.padEnd(8, ' '), parsedSimplified);
|
||
|
console.log('finalT'.padEnd(8, ' '), finalHint);
|
||
|
}
|
||
|
throw Error(title);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function validateText(text) {
|
||
|
// hints can be empty; empty hints don't need validating.
|
||
|
if (!text) {
|
||
|
return { valid: true };
|
||
|
}
|
||
|
|
||
|
// the trailing \n will not affect the final html, so we can trim. At worst
|
||
|
// there will be <br> difference between the two.
|
||
|
text = text.trim();
|
||
|
|
||
|
let parsed = mdToHTML(text).contents;
|
||
|
|
||
|
// parsed text is expected to get wrapped in p tags, so we remove them.
|
||
|
// NOTE: this is a bit zealous, but allowing any p tags generates a ton of
|
||
|
// false positives.
|
||
|
if (parsed.match(/^<p>.*<\/p>$/s)) {
|
||
|
parsed = parsed.replace(/<p>/g, '').replace(/<\/p>/g, '');
|
||
|
} else if (parsed.match(/^<p>.*<\/p>\n<pre>/)) {
|
||
|
parsed = parsed.match(/^<p>(.*)<\/p>/s)[1];
|
||
|
text = text.match(/^(.*?)```/s)[1];
|
||
|
} else if (
|
||
|
parsed.match(/^<pre><code>/) ||
|
||
|
parsed.match(/^<pre><code\s*class/)
|
||
|
) {
|
||
|
// TODO: figure out how to handle the
|
||
|
/*
|
||
|
question: | text
|
||
|
```lang
|
||
|
sfasfd
|
||
|
```
|
||
|
|
||
|
*/
|
||
|
// type of question. Actually, since this is already md format, we can
|
||
|
// probably let these through. Just check that they match the <p>stuff</p>
|
||
|
// <pre> blah and we should be okay.
|
||
|
|
||
|
// throw Error(`Unexpected parse result ${parsed}`);
|
||
|
return { valid: true, parsed };
|
||
|
} else {
|
||
|
throw Error(`Unexpected parse result ${parsed}`);
|
||
|
}
|
||
|
|
||
|
if (text === parsed) {
|
||
|
return { valid: true, parsed };
|
||
|
}
|
||
|
|
||
|
// it's possible the hint contained ` not code tags, so we replace in both
|
||
|
// also trimming because we know whitespace is actually preserved by the mdx
|
||
|
// parser
|
||
|
|
||
|
let finalParsed = parsed.replace(/<code>/g, '`').replace(/<\/code>/g, '`');
|
||
|
let finalHint = text.replace(/<code>/g, '`').replace(/<\/code>/g, '`');
|
||
|
|
||
|
// I've verified that whitespace is preserved by formatting and that the mdx
|
||
|
// parser also preserves it when it should (i.e. inside ``). So, for
|
||
|
// simplicity, I'm collapsing it here.
|
||
|
finalParsed = finalParsed.replace(/\s+/g, '');
|
||
|
finalHint = finalHint.replace(/\s+/g, '');
|
||
|
// TODO: is this too lax? Just forcing them both to use the decoded
|
||
|
// characters.
|
||
|
finalParsed = he.decode(finalParsed);
|
||
|
finalHint = he.decode(finalHint);
|
||
|
|
||
|
return {
|
||
|
valid: finalHint === finalParsed,
|
||
|
parsed,
|
||
|
parsedSimplified: finalParsed,
|
||
|
finalHint
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function hintToMd(hint, title) {
|
||
|
// we're only interested in `code` and want to avoid catching ```code```
|
||
|
const codeRE = /(?<!`)`[^`]+`(?!`)/g;
|
||
|
let wrappedCode = wrapCode(hint, title);
|
||
|
let wrappedUrls = wrapUrls(wrappedCode, title);
|
||
|
const pretty = wrappedUrls.split(codeRE).map(text => {
|
||
|
// prettify discards whitespace, which we generally want to keep.
|
||
|
if (text.match(/^\s*$/)) {
|
||
|
return text;
|
||
|
} else {
|
||
|
// bit of hack: we need to keep trailing newlines because they might be
|
||
|
// meaningful. prettifySync should respect them, but it's clearly being
|
||
|
// overzealous.
|
||
|
const leadingBreaks = text.match(/^\n*/)[0];
|
||
|
const rest = text.slice(leadingBreaks.length);
|
||
|
// prettify *also* adds a trailing \n which we generally don't want to
|
||
|
// keep
|
||
|
return leadingBreaks + prettifySync(rest).contents.slice(0, -1);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const code = [...wrappedUrls.matchAll(codeRE)].map(match => match[0]);
|
||
|
let newHint = [];
|
||
|
pretty.forEach((text, idx) => {
|
||
|
newHint.push(text);
|
||
|
if (typeof code[idx] !== 'undefined') {
|
||
|
newHint.push(code[idx]);
|
||
|
}
|
||
|
});
|
||
|
// depending on how the hint is represented in yaml, it can have extra \n
|
||
|
// chars at the end, so we should trim.
|
||
|
newHint = newHint.join('').trim();
|
||
|
|
||
|
return newHint;
|
||
|
}
|
||
|
|
||
|
function wrapCode(hint) {
|
||
|
if (typeof hint !== 'string') {
|
||
|
return '';
|
||
|
}
|
||
|
let mdHint;
|
||
|
function replacer(match, p1) {
|
||
|
// transform then remove the trailing \n
|
||
|
// Using pre is a dirty hack to make sure the whitespace is preserved
|
||
|
return codeToBackticksSync(`<pre>${p1}</pre>`).contents.slice(0, -1);
|
||
|
}
|
||
|
const codeRE = /<code>(.*?)<\/code>/g;
|
||
|
// to avoid parsing the rest of the markdown we use codeToBackticksSync on the
|
||
|
// code inside the re. If it fails, the code could be complicated enough to
|
||
|
// fool the regex, so we log it for human validation.
|
||
|
try {
|
||
|
mdHint = hint.replace(codeRE, replacer);
|
||
|
} catch (err) {
|
||
|
// console.log('err', err);
|
||
|
// console.log(`${title} failed
|
||
|
// hint:
|
||
|
// ${hint}`);
|
||
|
mdHint = hint.replace(codeRE, '`$1`');
|
||
|
// console.log('produced:');
|
||
|
// console.log(mdHint);
|
||
|
// console.log();
|
||
|
}
|
||
|
return mdHint;
|
||
|
}
|
||
|
|
||
|
function createSolutions({ solutions }) {
|
||
|
if (!solutions) return '';
|
||
|
const solutionsStr = solutions.map(soln => solutionToText(soln)).join(`
|
||
|
---
|
||
|
|
||
|
`);
|
||
|
return `# --solutions--
|
||
|
|
||
|
${solutionsStr}`;
|
||
|
}
|
||
|
|
||
|
function createQuestion({ question, title }) {
|
||
|
if (!question) return '';
|
||
|
const { text, answers, solution } = question;
|
||
|
return `# --question--
|
||
|
|
||
|
## --text--
|
||
|
|
||
|
${hintToMd(text, title)}
|
||
|
|
||
|
## --answers--
|
||
|
|
||
|
${answers.map(answer => hintToMd(answer, title)).join(`
|
||
|
|
||
|
---
|
||
|
|
||
|
`)}
|
||
|
|
||
|
## --video-solution--
|
||
|
|
||
|
${solution}
|
||
|
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
// files: {
|
||
|
// indexhtml: {
|
||
|
// key: 'indexhtml',
|
||
|
// ext: 'html',
|
||
|
// name: 'index',
|
||
|
// contents: '<h1>Hello</h1>\n',
|
||
|
// head: '',
|
||
|
// tail: '',
|
||
|
// editableRegionBoundaries: []
|
||
|
// }
|
||
|
// }
|
||
|
|
||
|
function createSeed({ files }) {
|
||
|
if (!files) return '';
|
||
|
const supportedLanguages = ['html', 'css', 'js', 'jsx'];
|
||
|
const supportedIndexes = supportedLanguages.map(lang => 'index' + lang);
|
||
|
Object.values(files).forEach(({ ext }) => {
|
||
|
if (!supportedLanguages.includes(ext)) throw `Unsupported language: ${ext}`;
|
||
|
});
|
||
|
Object.keys(files).forEach(index => {
|
||
|
if (!supportedIndexes.includes(index)) throw `Unsupported index: ${index}`;
|
||
|
});
|
||
|
|
||
|
const head = Object.values(files)
|
||
|
.filter(({ head }) => !isEmpty(head))
|
||
|
.map(({ ext, head }) => fenceCode(ext, head))
|
||
|
.join('\n');
|
||
|
|
||
|
const tail = Object.values(files)
|
||
|
.filter(({ tail }) => !isEmpty(tail))
|
||
|
.map(({ ext, tail }) => fenceCode(ext, tail))
|
||
|
.join('\n');
|
||
|
|
||
|
const contents = Object.values(files)
|
||
|
.map(({ ext, contents, editableRegionBoundaries }) =>
|
||
|
fenceCode(ext, insertMarkers(contents, editableRegionBoundaries))
|
||
|
)
|
||
|
.join('\n');
|
||
|
|
||
|
return (
|
||
|
`# --seed--
|
||
|
|
||
|
` +
|
||
|
createSection('before-user-code', head, 2) +
|
||
|
createSection('after-user-code', tail, 2) +
|
||
|
createSection('seed-contents', contents, 2)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function insertMarkers(code, markers) {
|
||
|
const lines = code.split('\n');
|
||
|
return markers
|
||
|
.reduce((acc, idx) => {
|
||
|
return insert(acc, '--fcc-editable-region--', idx);
|
||
|
}, lines)
|
||
|
.join('\n');
|
||
|
}
|
||
|
|
||
|
function insert(xs, x, idx) {
|
||
|
return [...xs.slice(0, idx), x, ...xs.slice(idx)];
|
||
|
}
|
||
|
|
||
|
function solutionToText(solution) {
|
||
|
const supportedLanguages = ['html', 'css', 'js', 'jsx', 'py'];
|
||
|
const supportedIndexes = supportedLanguages.map(lang => 'index' + lang);
|
||
|
Object.values(solution).forEach(({ ext }) => {
|
||
|
if (!supportedLanguages.includes(ext)) throw `Unsupported language: ${ext}`;
|
||
|
});
|
||
|
Object.keys(solution).forEach(index => {
|
||
|
if (!supportedIndexes.includes(index)) throw `Unsupported index: ${index}`;
|
||
|
});
|
||
|
|
||
|
return Object.values(solution)
|
||
|
.map(({ ext, contents }) => fenceCode(ext, contents))
|
||
|
.join('\n');
|
||
|
}
|
||
|
|
||
|
// Even if there is no code, we should fence it in case the extension is used
|
||
|
function fenceCode(ext, code) {
|
||
|
return `${'```' + ext}
|
||
|
${code + '```'}
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
function createInstructions({ instructions }) {
|
||
|
return createSection('instructions', instructions);
|
||
|
}
|
||
|
|
||
|
function createDescription({ description }) {
|
||
|
return createSection('description', description);
|
||
|
}
|
||
|
|
||
|
function createSection(heading, contents, depth = 1) {
|
||
|
return contents && contents.trim()
|
||
|
? `${''.padEnd(depth, '#')} --${heading}--
|
||
|
|
||
|
${contents}
|
||
|
`
|
||
|
: '';
|
||
|
}
|
||
|
|
||
|
function challengeToString(data) {
|
||
|
return (
|
||
|
createFrontmatter(data) +
|
||
|
createDescription(data) +
|
||
|
createInstructions(data) +
|
||
|
createQuestion(data) +
|
||
|
createHints(data) +
|
||
|
createSeed(data) +
|
||
|
createSolutions(data)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
exports.challengeToString = challengeToString;
|
||
|
exports.validateHints = validateHints;
|
||
|
|
||
|
// console.log(exports.challengeToString(challengeData));
|
||
|
// // exports.challengeToString(challengeData);
|
||
|
|
||
|
// console.log(
|
||
|
// hintToMd('ZigZagMatrix(2) should return [[0, 1], [2, 3]].', 'title')
|
||
|
// );
|
||
|
|
||
|
// console.log(hintToMd('lar@freecodecamp.org', 'title'));
|