398 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
		
		
			
		
	
	
			398 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
|   | const unified = require('unified'); | ||
|  | const markdown = require('remark-parse'); | ||
|  | const remark2rehype = require('remark-rehype'); | ||
|  | const stringify = require('remark-stringify'); | ||
|  | const frontmatter = require('remark-frontmatter'); | ||
|  | const raw = require('rehype-raw'); | ||
|  | const visit = require('unist-util-visit'); | ||
|  | const vfile = require('to-vfile'); | ||
|  | const path = require('path'); | ||
|  | const { Transform } = require('stream'); | ||
|  | const { Translate } = require('@google-cloud/translate'); | ||
|  | const YAML = require('js-yaml'); | ||
|  | 
 | ||
|  | const solutionsToData = require('./solution-to-data'); | ||
|  | const challengeSeedToData = require('./challengeSeed-to-data'); | ||
|  | 
 | ||
|  | const transformChallenge = new Transform({ | ||
|  |   transform(chunk, encoding, callback) { | ||
|  |     const fileName = chunk.toString().trim(); | ||
|  | 
 | ||
|  |     rebuildChallengeFile(fileName) | ||
|  |       .then(file => callback(null, String(file.contents))) | ||
|  |       .catch(err => console.error(err)); | ||
|  |   } | ||
|  | }); | ||
|  | 
 | ||
|  | process.stdin.pipe(transformChallenge).pipe(process.stdout); | ||
|  | 
 | ||
|  | const processor = unified() | ||
|  |   .use(markdown) | ||
|  |   .use(frontmatter, ['yaml']) | ||
|  |   .use(frontmatterToData) | ||
|  |   .use(testsToData) | ||
|  |   .use(textToData, ['description', 'instructions']) | ||
|  |   .use(remark2rehype, { allowDangerousHTML: true }) | ||
|  |   .use(raw) | ||
|  |   .use(solutionsToData) | ||
|  |   .use(challengeSeedToData) | ||
|  |   .use(replaceWithReferenceData) | ||
|  |   .use(output); | ||
|  | 
 | ||
|  | exports.rebuildChallengeFile = rebuildChallengeFile; | ||
|  | 
 | ||
|  | async function rebuildChallengeFile(fileName) { | ||
|  |   const filePath = path.resolve(fileName); | ||
|  |   const lang = detectLang(filePath); | ||
|  |   let referenceChallenge; | ||
|  |   let translateText; | ||
|  |   if (lang !== 'english') { | ||
|  |     referenceChallenge = await getReferenceChallengeData(filePath); | ||
|  |     translateText = createTranslateText(lang); | ||
|  |   } | ||
|  | 
 | ||
|  |   const file = await vfile.read(filePath); | ||
|  |   file.data = { | ||
|  |     ...file.data, | ||
|  |     lang, | ||
|  |     referenceChallenge, | ||
|  |     translateText | ||
|  |   }; | ||
|  |   return await processor.process(file); | ||
|  | } | ||
|  | 
 | ||
|  | async function getReferenceChallengeData(filePath) { | ||
|  |   const parts = filePath.split(path.sep); | ||
|  |   parts.push(parts.pop().replace(/\.[^.]+\.md$/, '.english.md')); | ||
|  |   parts[parts.length - 4] = 'english'; | ||
|  |   const filePathEnglishChallenge = parts.join(path.sep); | ||
|  |   try { | ||
|  |     const fileData = await vfile.read(filePathEnglishChallenge); | ||
|  |     fileData.data = { ...fileData.data, refData: true }; | ||
|  |     return (await processor.process(fileData)).data; | ||
|  |   } catch (err) { | ||
|  |     console.error(err); | ||
|  |     return null; | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | function detectLang(filePath) { | ||
|  |   const match = /\.([^.]+)\.md$/.exec(filePath); | ||
|  |   if (!match) { | ||
|  |     throw new Error(`Incorrect file path ${filePath}`); | ||
|  |   } | ||
|  |   return match[1]; | ||
|  | } | ||
|  | 
 | ||
|  | function frontmatterToData() { | ||
|  |   return transformer; | ||
|  | 
 | ||
|  |   function transformer(tree, file) { | ||
|  |     visit(tree, 'yaml', visitor); | ||
|  | 
 | ||
|  |     function visitor(node) { | ||
|  |       const frontmatter = node.value; | ||
|  | 
 | ||
|  |       file.data = { ...file.data, frontmatter }; | ||
|  |     } | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | function testsToData() { | ||
|  |   return (tree, file) => { | ||
|  |     visit(tree, 'code', visitor); | ||
|  | 
 | ||
|  |     function visitor(node) { | ||
|  |       const { lang, value } = node; | ||
|  |       if (lang === 'yml') { | ||
|  |         file.data = { | ||
|  |           ...file.data, | ||
|  |           tests: value | ||
|  |         }; | ||
|  |       } | ||
|  |     } | ||
|  |   }; | ||
|  | } | ||
|  | 
 | ||
|  | function textToData(sectionIds) { | ||
|  |   return (tree, file) => { | ||
|  |     let indexId = 0; | ||
|  |     let currentSection = sectionIds[indexId]; | ||
|  |     let inSection = false; | ||
|  |     let nodes = []; | ||
|  |     let findSection; | ||
|  |     const visitor = (node, index, parent) => { | ||
|  |       if (!parent) { | ||
|  |         return visit.CONTINUE; | ||
|  |       } | ||
|  | 
 | ||
|  |       if (node.type === 'heading') { | ||
|  |         if (inSection) { | ||
|  |           findSection = new RegExp(`^<section id=('|")${currentSection}\\1>`); | ||
|  |           file.data = { | ||
|  |             ...file.data, | ||
|  |             [currentSection]: new stringify.Compiler( | ||
|  |               { type: 'root', children: nodes }, | ||
|  |               file | ||
|  |             ) | ||
|  |               .compile() | ||
|  |               .trim() | ||
|  |               .replace(findSection, '') | ||
|  |               .replace(/<\/section>$/, '') | ||
|  |               .trim() | ||
|  |           }; | ||
|  |           nodes = []; | ||
|  |           indexId++; | ||
|  |           if (indexId < sectionIds.length) { | ||
|  |             currentSection = sectionIds[indexId]; | ||
|  |           } else { | ||
|  |             return visit.EXIT; | ||
|  |           } | ||
|  |         } | ||
|  |         inSection = true; | ||
|  |       } else if (inSection) { | ||
|  |         nodes.push(node); | ||
|  |       } | ||
|  | 
 | ||
|  |       return visit.SKIP; | ||
|  |     }; | ||
|  |     visit(tree, visitor); | ||
|  |   }; | ||
|  | } | ||
|  | 
 | ||
|  | function createTranslateText(target, source = 'english') { | ||
|  |   const projectId = process.env.GOOGLE_CLOUD_PROJECT_ID; | ||
|  |   if (!projectId) { | ||
|  |     return async text => text; | ||
|  |   } | ||
|  |   const languageCodes = { | ||
|  |     arabic: 'ar', | ||
|  |     chinese: 'zh', | ||
|  |     english: 'en', | ||
|  |     portuguese: 'pt', | ||
|  |     russian: 'ru', | ||
|  |     spanish: 'es' | ||
|  |   }; | ||
|  |   const from = languageCodes[source]; | ||
|  |   const to = languageCodes[target]; | ||
|  |   return async text => { | ||
|  |     if (!text) { | ||
|  |       return text; | ||
|  |     } | ||
|  |     try { | ||
|  |       const translate = new Translate({ projectId }); | ||
|  |       const result = await translate.translate(text, { from, to }); | ||
|  |       const translations = result[0]; | ||
|  |       return translations; | ||
|  |     } catch (err) { | ||
|  |       // console.error(err);
 | ||
|  |       return text; | ||
|  |     } | ||
|  |   }; | ||
|  | } | ||
|  | 
 | ||
|  | async function processTests(tests, referenceTests, translateText) { | ||
|  |   const testsObject = YAML.load(referenceTests); | ||
|  |   if ( | ||
|  |     !testsObject.tests || | ||
|  |     testsObject.tests.length === 0 || | ||
|  |     !testsObject.tests[0].text | ||
|  |   ) { | ||
|  |     return referenceTests; | ||
|  |   } | ||
|  |   const newTests = await Promise.all( | ||
|  |     testsObject.tests.map(async test => { | ||
|  |       const text = await translateText(test.text); | ||
|  |       return { ...test, text }; | ||
|  |     }) | ||
|  |   ); | ||
|  |   const testStrings = newTests | ||
|  |     .map( | ||
|  |       ({ text, testString }) => | ||
|  |         `  - text: ${dumpToYamlString(text)}    testString: ${dumpToYamlString( | ||
|  |           testString | ||
|  |         )}`
 | ||
|  |     ) | ||
|  |     .join(''); | ||
|  |   return `tests:${testStrings ? '\n' + testStrings : ' []\n'}`; | ||
|  | } | ||
|  | 
 | ||
|  | function dumpToYamlString(text) { | ||
|  |   let fromYaml; | ||
|  |   try { | ||
|  |     fromYaml = YAML.load(text); | ||
|  |   } catch { | ||
|  |     // console.error(`YAML load: ${text}`);
 | ||
|  |   } | ||
|  |   if (text === fromYaml) { | ||
|  |     return text + '\n'; | ||
|  |   } | ||
|  |   return YAML.dump(text, { lineWidth: 10000 }); | ||
|  | } | ||
|  | 
 | ||
|  | async function processFrontmatter(fileData) { | ||
|  |   const { referenceChallenge, lang, translateText } = fileData; | ||
|  |   const challengeData = YAML.load(fileData.frontmatter); | ||
|  |   let data; | ||
|  |   if (referenceChallenge) { | ||
|  |     data = YAML.load(referenceChallenge.frontmatter); | ||
|  |   } else { | ||
|  |     data = challengeData; | ||
|  |   } | ||
|  | 
 | ||
|  |   if (lang && lang !== 'english') { | ||
|  |     if (challengeData.localeTitle) { | ||
|  |       data.localeTitle = challengeData.localeTitle; | ||
|  |     } else { | ||
|  |       data.localeTitle = await translateText(data.title); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   fileData.frontmatter = Object.entries(data) | ||
|  |     .map(([name, value]) => { | ||
|  |       if (typeof value === 'object') { | ||
|  |         return `${name}:
 | ||
|  |   ${dumpToYamlString(value) | ||
|  |     .replace(/\n/, '\n  ') | ||
|  |     .trimRight()} | ||
|  | `;
 | ||
|  |       } | ||
|  |       return `${name}: ${dumpToYamlString(value)}`; | ||
|  |     }) | ||
|  |     .join('') | ||
|  |     .trimRight(); | ||
|  | } | ||
|  | 
 | ||
|  | function replaceWithReferenceData() { | ||
|  |   return async (tree, file) => { | ||
|  |     if (file.data.refData) { | ||
|  |       return; | ||
|  |     } | ||
|  | 
 | ||
|  |     const { referenceChallenge, translateText } = file.data; | ||
|  | 
 | ||
|  |     await processFrontmatter(file.data); | ||
|  | 
 | ||
|  |     if (referenceChallenge) { | ||
|  |       const { description, instructions } = file.data; | ||
|  | 
 | ||
|  |       if (!description || description === 'undefined') { | ||
|  |         file.data.description = await translateText( | ||
|  |           referenceChallenge.description | ||
|  |         ); | ||
|  |       } | ||
|  |       if (!instructions || instructions === 'undefined') { | ||
|  |         file.data.instructions = await translateText( | ||
|  |           referenceChallenge.instructions | ||
|  |         ); | ||
|  |       } | ||
|  |       file.data.tests = await processTests( | ||
|  |         file.data.tests, | ||
|  |         referenceChallenge.tests, | ||
|  |         translateText | ||
|  |       ); | ||
|  |       file.data.files = referenceChallenge.files; | ||
|  |       file.data.solutions = referenceChallenge.solutions; | ||
|  |     } | ||
|  |   }; | ||
|  | } | ||
|  | 
 | ||
|  | function output() { | ||
|  |   this.Compiler = function(tree, file) { | ||
|  |     let { | ||
|  |       frontmatter, | ||
|  |       description, | ||
|  |       instructions, | ||
|  |       tests, | ||
|  |       files: [challengeFile = {}] | ||
|  |     } = file.data; | ||
|  |     const { ext, contents, head, tail } = challengeFile; | ||
|  |     let { solutions = [] } = file.data; | ||
|  |     solutions = solutions | ||
|  |       .map(s => s.trim()) | ||
|  |       .map(s => | ||
|  |         !s.includes('\n') && /^\/\//.test(s) ? '// solution required' : s | ||
|  |       ); | ||
|  |     return `---
 | ||
|  | ${frontmatter} | ||
|  | --- | ||
|  | 
 | ||
|  | ## Description | ||
|  | <section id='description'> | ||
|  | ${description} | ||
|  | </section> | ||
|  | 
 | ||
|  | ## Instructions | ||
|  | <section id='instructions'> | ||
|  | ${instructions} | ||
|  | </section> | ||
|  | 
 | ||
|  | ## Tests | ||
|  | <section id='tests'> | ||
|  | 
 | ||
|  | \`\`\`yml
 | ||
|  | ${tests} | ||
|  | \`\`\`
 | ||
|  | 
 | ||
|  | </section> | ||
|  | 
 | ||
|  | ${ | ||
|  |   ext | ||
|  |     ? `## Challenge Seed
 | ||
|  | <section id='challengeSeed'> | ||
|  | 
 | ||
|  | <div id='${ext}-seed'> | ||
|  | 
 | ||
|  | \`\`\`${ext}
 | ||
|  | ${contents} | ||
|  | \`\`\`
 | ||
|  | 
 | ||
|  | </div>${ | ||
|  |         head | ||
|  |           ? `
 | ||
|  | 
 | ||
|  | ### Before Tests | ||
|  | <div id='${ext}-setup'> | ||
|  | 
 | ||
|  | \`\`\`${ext}
 | ||
|  | ${head} | ||
|  | \`\`\`
 | ||
|  | 
 | ||
|  | </div>` | ||
|  |           : '' | ||
|  |       }${ | ||
|  |         tail | ||
|  |           ? `
 | ||
|  | 
 | ||
|  | ### After Tests | ||
|  | <div id='${ext}-teardown'> | ||
|  | 
 | ||
|  | \`\`\`${ext}
 | ||
|  | ${tail} | ||
|  | \`\`\`
 | ||
|  | 
 | ||
|  | </div>` | ||
|  |           : '' | ||
|  |       } | ||
|  | 
 | ||
|  | </section> | ||
|  | 
 | ||
|  | ## Solution | ||
|  | ${solutions.reduce( | ||
|  |   (result, solution) => | ||
|  |     result + | ||
|  |     `<section id='solution'>
 | ||
|  | 
 | ||
|  | \`\`\`${ext}
 | ||
|  | ${solution.trim()} | ||
|  | \`\`\`
 | ||
|  | 
 | ||
|  | </section>`, | ||
|  |   '' | ||
|  | )} | ||
|  | `
 | ||
|  |     : '' | ||
|  | }`;
 | ||
|  |   }; | ||
|  | } |