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
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>$/s)) { parsed = parsed.replace(/
/g, '').replace(/<\/p>/g, ''); } else if (parsed.match(/^
.*<\/p>\n
/)) { parsed = parsed.match(/^(.*)<\/p>/s)[1]; text = text.match(/^(.*?)```/s)[1]; } else if ( parsed.match(/^
/) || parsed.match(/^
stuff //
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(//g, '`').replace(/<\/code>/g, '`'); let finalHint = text.replace(/
/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 = /(? { // 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(`
${p1}`).contents.slice(0, -1); } const codeRE = /(.*?)<\/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: '
Hello
\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'));