feat(challenge-parser): add validateSections plugin (#61148)

This commit is contained in:
Huyen Nguyen
2025-08-14 18:39:18 +07:00
committed by GitHub
parent 5d2adfa058
commit 9ebdd29205
3 changed files with 377 additions and 0 deletions

View File

@@ -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" }

View File

@@ -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;

View File

@@ -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--".'
);
});
});