From 9ebdd29205d4d44c8dbe1ad3d2db8f067d6db7a6 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:39:18 +0700 Subject: [PATCH] feat(challenge-parser): add validateSections plugin (#61148) --- tools/challenge-parser/parser/index.js | 3 + .../parser/plugins/validate-sections.js | 126 +++++++++ .../parser/plugins/validate-sections.test.js | 248 ++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 tools/challenge-parser/parser/plugins/validate-sections.js create mode 100644 tools/challenge-parser/parser/plugins/validate-sections.test.js diff --git a/tools/challenge-parser/parser/index.js b/tools/challenge-parser/parser/index.js index d8b76a4bf26..231f15d38eb 100644 --- a/tools/challenge-parser/parser/index.js +++ b/tools/challenge-parser/parser/index.js @@ -5,6 +5,7 @@ const { readSync } = require('to-vfile'); const unified = require('unified'); const addFillInTheBlank = require('./plugins/add-fill-in-the-blank'); const addFrontmatter = require('./plugins/add-frontmatter'); +const validateSections = require('./plugins/validate-sections'); const addSeed = require('./plugins/add-seed'); const addSolution = require('./plugins/add-solution'); const addHooks = require('./plugins/add-hooks'); @@ -32,6 +33,8 @@ const processor = unified() .use(frontmatter, ['yaml']) // extract the content from that 'yaml' node .use(addFrontmatter) + // validate all section markers before any plugin tries to extract sections + .use(validateSections) // Any imports will be replaced (in the tree) with // the sub-tree of the target file. e.g. // ::import{component="Script" from="./file.path" } diff --git a/tools/challenge-parser/parser/plugins/validate-sections.js b/tools/challenge-parser/parser/plugins/validate-sections.js new file mode 100644 index 00000000000..d20aa0b9c23 --- /dev/null +++ b/tools/challenge-parser/parser/plugins/validate-sections.js @@ -0,0 +1,126 @@ +const { findAll } = require('./utils/find-all'); +const { isMarker } = require('./utils/get-section'); + +const VALID_MARKERS = [ + // Level 1 + '# --after-all--', + '# --after-each--', + '# --assignment--', + '# --before-all--', + '# --before-each--', + '# --description--', + '# --explanation--', + '# --fillInTheBlank--', + '# --hints--', + '# --instructions--', + '# --notes--', + '# --questions--', + '# --quizzes--', + '# --scene--', + '# --seed--', + '# --solutions--', + '# --transcript--', + + // Level 2 + '## --answers--', + '## --blanks--', + '## --quiz--', + '## --seed-contents--', + '## --sentence--', + '## --text--', + '## --video-solution--', + // TODO: Remove these two markers when https://github.com/freeCodeCamp/freeCodeCamp/issues/57107 is resolved + '## --after-user-code--', + '## --before-user-code--', + + // Level 3 + '### --feedback--', + '### --question--', + + // Level 4 + '#### --answer--', + '#### --distractors--', + '#### --text--' +]; + +// Special markers that should not be used as headings +const NON_HEADING_MARKERS = ['--fcc-editable-region--']; + +function validateSections() { + function transformer(tree) { + const allMarkers = findAll(tree, isMarker); + + const invalidMarkerNames = []; + const invalidHeadingLevels = []; + const nonHeadingMarkersAsHeadings = []; + + const errors = []; + + for (const markerNode of allMarkers) { + const markerValue = markerNode.children[0].value; + const headingLevel = markerNode.depth; + const fullMarker = '#'.repeat(headingLevel) + ' ' + markerValue; + + if (NON_HEADING_MARKERS.includes(markerValue)) { + nonHeadingMarkersAsHeadings.push(fullMarker); + continue; + } + + if (!VALID_MARKERS.includes(fullMarker)) { + const markerExistsAtAnyLevel = VALID_MARKERS.some(validMarker => + validMarker.endsWith(markerValue) + ); + + if (markerExistsAtAnyLevel) { + const validLevels = VALID_MARKERS.filter(validMarker => + validMarker.endsWith(markerValue) + ).map(validMarker => validMarker.split(' ')[0]); // Extract the # symbols + + invalidHeadingLevels.push({ + fullMarker, + markerValue, + validLevels + }); + } else { + invalidMarkerNames.push(markerValue); + } + } + } + + if (invalidMarkerNames.length > 0) { + errors.push( + `Invalid marker names: ${invalidMarkerNames.map(m => `"${m}"`).join(', ')}.` + ); + } + + if (nonHeadingMarkersAsHeadings.length > 0) { + errors.push( + `Non-heading markers should not be used as headings: ${nonHeadingMarkersAsHeadings.map(m => `"${m}"`).join(', ')}.` + ); + } + + if (invalidHeadingLevels.length > 0) { + const levelErrors = invalidHeadingLevels.map( + ({ fullMarker, markerValue, validLevels }) => { + const validText = + validLevels.length === 1 + ? `${validLevels[0]} ${markerValue}` + : validLevels + .map(level => `${level} ${markerValue}`) + .join(' or '); + return `"${fullMarker}" should be "${validText}"`; + } + ); + + errors.push(`Invalid heading levels: ${levelErrors.join(', ')}.`); + } + + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + } + + return transformer; +} + +module.exports = validateSections; diff --git a/tools/challenge-parser/parser/plugins/validate-sections.test.js b/tools/challenge-parser/parser/plugins/validate-sections.test.js new file mode 100644 index 00000000000..bc750848ce2 --- /dev/null +++ b/tools/challenge-parser/parser/plugins/validate-sections.test.js @@ -0,0 +1,248 @@ +const unified = require('unified'); +const remark = require('remark-parse'); +const frontmatter = require('remark-frontmatter'); +const addFrontmatter = require('./add-frontmatter'); +const validateSections = require('./validate-sections'); + +const processor = unified() + .use(remark) + .use(frontmatter, ['yaml']) + .use(addFrontmatter) + .use(validateSections); + +describe('validate-sections plugin', () => { + it('should pass when all section markers are valid', () => { + const file = `--- +id: test +title: Test +--- + +# --after-all-- +After all hook. + +# --after-each-- +After each hook. + +# --assignment-- +Assignment. + +# --before-all-- +Before all hook. + +# --before-each-- +Before each hook. + +# --description-- +Text content. + +# --explanation-- +Explanation. + +# --fillInTheBlank-- +Fill in blank. + +# --hints-- +Hints. + +# --instructions-- +More text. + +# --notes-- +Notes. + +# --questions-- +Video questions. + +# --quizzes-- +Quiz section. + +# --scene-- +Scene content. + +# --seed-- +Seed section. + +# --solutions-- +Solutions. + +# --transcript-- +Transcript. + +## --answers-- +Answers. + +## --blanks-- +Blanks. + +## --quiz-- +Individual quiz. + +## --seed-contents-- +Contents here. + +## --sentence-- +Sentence. + +## --text-- +Question text. + +## --video-solution-- +Video solution. + +## --after-user-code-- +After code. + +## --before-user-code-- +Before code. + +### --feedback-- +Feedback. + +### --question-- +Quiz question. + +#### --answer-- +Correct answer. + +#### --distractors-- +Distractors. + +#### --text-- +Question text. + +--fcc-editable-region-- +Editable region. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).not.toThrow(); + }); + + it('should throw error for invalid marker names', () => { + const file = `--- +id: test +title: Test +--- + +# --descriptio-- +Typo in marker name. + +# --instructionss-- +Another typo. + +# -- instructions-- +Another typo. + +# --feedback--- +Another typo. + +# --invalid-marker-- +Completely invalid marker. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).toThrow( + 'Invalid marker names: "--descriptio--", "--instructionss--", "-- instructions--", "--feedback---", "--invalid-marker--".' + ); + }); + + it('should validate case-sensitive markers', () => { + const file = `--- +id: test +title: Test +--- + +# --INSTRUCTIONS-- +Wrong case. + +# --Instructions-- +Also wrong case. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).toThrow('Invalid marker names: "--INSTRUCTIONS--", "--Instructions--".'); + }); + + it('should throw error for correct marker at wrong heading level', () => { + const file = `--- +id: test +title: Test +--- + +## --instructions-- +Instructions should be at level 1, not 2. + +### --seed-contents-- +Seed contents should be at level 2, not 3. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).toThrow( + 'Invalid heading levels: "## --instructions--" should be "# --instructions--", "### --seed-contents--" should be "## --seed-contents--".' + ); + }); + + it('should throw combined errors for invalid markers and wrong levels', () => { + const file = `--- +id: test +title: Test +--- + +## --instructions-- +Wrong level. + +# --invalid-marker-- +Invalid marker. + +### --seed-contents-- +Wrong level. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).toThrow( + 'Invalid marker names: "--invalid-marker--".\nInvalid heading levels: "## --instructions--" should be "# --instructions--", "### --seed-contents--" should be "## --seed-contents--".' + ); + }); + + it('should throw error for fcc-editable-region when used as headings', () => { + const file = `--- +id: test +title: Test +--- + +# --fcc-editable-region-- +This should not be a heading. + +## --fcc-editable-region-- +This should also not be a heading. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).toThrow( + 'Non-heading markers should not be used as headings: "# --fcc-editable-region--", "## --fcc-editable-region--".' + ); + }); + + it('should throw error for markers valid at multiple levels but used at an invalid level', () => { + const file = `--- +id: test +title: Test +--- + +### --text-- +This marker is valid at level 2 or level 4, but not at level 3. +`; + + expect(() => { + processor.runSync(processor.parse(file)); + }).toThrow( + 'Invalid heading levels: "### --text--" should be "## --text-- or #### --text--".' + ); + }); +});