mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 10:22:16 +00:00
feat(parser): enforce workshop file validations (#66340)
Co-authored-by: sembauke <semboot699@gmail.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: abc123
|
||||
title: Step 1
|
||||
challengeType: 28
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Some description.
|
||||
|
||||
# --hints--
|
||||
```js
|
||||
assert(true);
|
||||
```
|
||||
|
||||
# --solutions--
|
||||
```js
|
||||
var x = 1;
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
id: abc456
|
||||
title: Step 2
|
||||
challengeType: 28
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Some description.
|
||||
|
||||
# --hints--
|
||||
```js
|
||||
assert(true);
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: abc789
|
||||
title: Step 1
|
||||
challengeType: 28
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Some description.
|
||||
|
||||
# --seed--
|
||||
|
||||
## --seed-contents--
|
||||
```html
|
||||
--fcc-editable-region--
|
||||
<p>First region</p>
|
||||
--fcc-editable-region--
|
||||
<p>Middle</p>
|
||||
--fcc-editable-region--
|
||||
<p>Second region</p>
|
||||
--fcc-editable-region--
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: step-1
|
||||
title: Step 1
|
||||
challengeType: 28
|
||||
---
|
||||
|
||||
# --description--
|
||||
|
||||
Placeholder fixture for parser path resolution tests.
|
||||
@@ -4,8 +4,14 @@ const visitChildren = require('unist-util-visit-children');
|
||||
const { getSection } = require('./utils/get-section');
|
||||
const { getFileVisitor } = require('./utils/get-file-visitor');
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const editableRegionMarker = '--fcc-editable-region--';
|
||||
|
||||
function isWorkshop(file) {
|
||||
return file.path && file.path.includes(path.sep + 'workshop-');
|
||||
}
|
||||
|
||||
function findRegionMarkers(challengeFile) {
|
||||
const lines = challengeFile.contents.split('\n');
|
||||
const editableLines = lines
|
||||
@@ -56,8 +62,13 @@ function addSeeds() {
|
||||
};
|
||||
|
||||
// process region markers - remove them from content and add them to data
|
||||
let totalEditableRegionMarkers = 0;
|
||||
|
||||
const challengeFiles = Object.values(seeds).map(data => {
|
||||
const seed = { ...data };
|
||||
// Per-file check: ensures no single seed file has more than 2 markers.
|
||||
// This is distinct from the workshop-level check below, which enforces
|
||||
// exactly 2 total markers across all seed files combined.
|
||||
const editRegionMarkers = findRegionMarkers(seed);
|
||||
if (editRegionMarkers) {
|
||||
seed.contents = removeLines(seed.contents, editRegionMarkers);
|
||||
@@ -66,12 +77,19 @@ function addSeeds() {
|
||||
throw Error('Editable region must be non zero');
|
||||
}
|
||||
seed.editableRegionBoundaries = editRegionMarkers;
|
||||
totalEditableRegionMarkers += editRegionMarkers.length;
|
||||
} else {
|
||||
seed.editableRegionBoundaries = [];
|
||||
}
|
||||
return seed;
|
||||
});
|
||||
|
||||
if (isWorkshop(file) && totalEditableRegionMarkers !== 2) {
|
||||
throw Error(
|
||||
`Workshop challenge ${file.path} must have exactly 2 editable region markers`
|
||||
);
|
||||
}
|
||||
|
||||
file.data = {
|
||||
...file.data,
|
||||
challengeFiles
|
||||
@@ -108,3 +126,4 @@ function validateEditableMarkers({ value, position }) {
|
||||
|
||||
module.exports = addSeeds;
|
||||
module.exports.editableRegionMarker = editableRegionMarker;
|
||||
module.exports.isWorkshop = isWorkshop;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const { isEmpty } = require('lodash');
|
||||
const { root } = require('mdast-builder');
|
||||
const visitChildren = require('unist-util-visit-children');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { editableRegionMarker } = require('./add-seed');
|
||||
const { editableRegionMarker, isWorkshop } = require('./add-seed');
|
||||
const { getSection } = require('./utils/get-section');
|
||||
const { getFileVisitor } = require('./utils/get-file-visitor');
|
||||
const { splitOnThematicBreak } = require('./utils/split-on-thematic-break');
|
||||
@@ -16,6 +18,52 @@ function validateMarkers({ value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function isLastStep(file) {
|
||||
const challengeDir = path.dirname(file.path);
|
||||
const blockName = path.basename(challengeDir);
|
||||
|
||||
let current = challengeDir;
|
||||
let blockJsonPath = null;
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
current = path.dirname(current);
|
||||
const candidate = path.join(
|
||||
current,
|
||||
'structure',
|
||||
'blocks',
|
||||
`${blockName}.json`
|
||||
);
|
||||
if (fs.existsSync(candidate)) {
|
||||
blockJsonPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!blockJsonPath) return true;
|
||||
|
||||
try {
|
||||
const blockData = JSON.parse(fs.readFileSync(blockJsonPath, 'utf8'));
|
||||
|
||||
// Upcoming blocks may still contain transitional content that does not yet
|
||||
// follow this invariant.
|
||||
if (blockData.isUpcomingChange) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const challengeOrder = blockData.challengeOrder;
|
||||
|
||||
if (!Array.isArray(challengeOrder) || challengeOrder.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentId = path.basename(file.path, '.md');
|
||||
const lastId = challengeOrder[challengeOrder.length - 1].id;
|
||||
|
||||
return currentId === lastId;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
function createPlugin() {
|
||||
return function transformer(tree, file) {
|
||||
const solutionArrays = splitOnThematicBreak(
|
||||
@@ -34,6 +82,30 @@ function createPlugin() {
|
||||
if (!isEmpty(solution)) solutions.push(Object.values(solution));
|
||||
});
|
||||
|
||||
if (isWorkshop(file)) {
|
||||
const seedSection = getSection(tree, `--seed--`);
|
||||
const seedContents = getSection(root(seedSection), `--seed-contents--`);
|
||||
let totalMarkers = 0;
|
||||
const seedTree = root(seedContents);
|
||||
seedTree.children.forEach(node => {
|
||||
if (node.value) {
|
||||
const lines = node.value.split('\n');
|
||||
lines.forEach(line => {
|
||||
if (line.trim() === '--fcc-editable-region--') totalMarkers++;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (totalMarkers > 0 && totalMarkers !== 2) {
|
||||
throw Error(
|
||||
`Workshop challenge ${file.path} must have exactly 2 editable region markers`
|
||||
);
|
||||
}
|
||||
if (solutions.length > 0 && !isLastStep(file)) {
|
||||
throw Error(
|
||||
`Workshop challenge ${file.path} has solutions but is not the last step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
file.data = {
|
||||
...file.data,
|
||||
solutions: solutions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'path';
|
||||
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
|
||||
import { isObject } from 'lodash';
|
||||
import parseFixture from '../__fixtures__/parse-fixture';
|
||||
@@ -94,4 +95,33 @@ describe('add solution plugin', () => {
|
||||
plugin(mockAST, file);
|
||||
expect(file.data).toMatchSnapshot();
|
||||
});
|
||||
it('should throw if a workshop non-last step has solutions', async () => {
|
||||
expect.assertions(1);
|
||||
const workshopNonLastAST = await parseFixture('with-multiple-solns.md');
|
||||
const workshopFile = {
|
||||
data: {},
|
||||
path: path.join(
|
||||
__dirname,
|
||||
'../__fixtures__/workshop-test-steps/step-1.md'
|
||||
)
|
||||
};
|
||||
expect(() => plugin(workshopNonLastAST, workshopFile)).toThrow(
|
||||
'has solutions but is not the last step'
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow solutions in non-last steps for upcoming workshop blocks', async () => {
|
||||
expect.assertions(1);
|
||||
const workshopNonLastAST = await parseFixture('with-multiple-solns.md');
|
||||
const upcomingWorkshopFile = {
|
||||
data: {},
|
||||
path: path.join(
|
||||
__dirname,
|
||||
'../__fixtures__/workshop-upcoming-test-steps/step-1.md'
|
||||
)
|
||||
};
|
||||
expect(() =>
|
||||
plugin(workshopNonLastAST, upcomingWorkshopFile)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"dashedName": "workshop-test-steps",
|
||||
"blockLabel": "workshop",
|
||||
"challengeOrder": [
|
||||
{ "id": "step-1", "title": "Step 1" },
|
||||
{ "id": "step-with-four-erms", "title": "Step with four erms" },
|
||||
{ "id": "step-2", "title": "Step 2" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"isUpcomingChange": true,
|
||||
"dashedName": "workshop-upcoming-test-steps",
|
||||
"blockLabel": "workshop",
|
||||
"challengeOrder": [
|
||||
{ "id": "step-1", "title": "Step 1" },
|
||||
{ "id": "step-2", "title": "Step 2" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user