mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +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 { getSection } = require('./utils/get-section');
|
||||||
const { getFileVisitor } = require('./utils/get-file-visitor');
|
const { getFileVisitor } = require('./utils/get-file-visitor');
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const editableRegionMarker = '--fcc-editable-region--';
|
const editableRegionMarker = '--fcc-editable-region--';
|
||||||
|
|
||||||
|
function isWorkshop(file) {
|
||||||
|
return file.path && file.path.includes(path.sep + 'workshop-');
|
||||||
|
}
|
||||||
|
|
||||||
function findRegionMarkers(challengeFile) {
|
function findRegionMarkers(challengeFile) {
|
||||||
const lines = challengeFile.contents.split('\n');
|
const lines = challengeFile.contents.split('\n');
|
||||||
const editableLines = lines
|
const editableLines = lines
|
||||||
@@ -56,8 +62,13 @@ function addSeeds() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// process region markers - remove them from content and add them to data
|
// process region markers - remove them from content and add them to data
|
||||||
|
let totalEditableRegionMarkers = 0;
|
||||||
|
|
||||||
const challengeFiles = Object.values(seeds).map(data => {
|
const challengeFiles = Object.values(seeds).map(data => {
|
||||||
const seed = { ...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);
|
const editRegionMarkers = findRegionMarkers(seed);
|
||||||
if (editRegionMarkers) {
|
if (editRegionMarkers) {
|
||||||
seed.contents = removeLines(seed.contents, editRegionMarkers);
|
seed.contents = removeLines(seed.contents, editRegionMarkers);
|
||||||
@@ -66,12 +77,19 @@ function addSeeds() {
|
|||||||
throw Error('Editable region must be non zero');
|
throw Error('Editable region must be non zero');
|
||||||
}
|
}
|
||||||
seed.editableRegionBoundaries = editRegionMarkers;
|
seed.editableRegionBoundaries = editRegionMarkers;
|
||||||
|
totalEditableRegionMarkers += editRegionMarkers.length;
|
||||||
} else {
|
} else {
|
||||||
seed.editableRegionBoundaries = [];
|
seed.editableRegionBoundaries = [];
|
||||||
}
|
}
|
||||||
return seed;
|
return seed;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isWorkshop(file) && totalEditableRegionMarkers !== 2) {
|
||||||
|
throw Error(
|
||||||
|
`Workshop challenge ${file.path} must have exactly 2 editable region markers`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
file.data = {
|
file.data = {
|
||||||
...file.data,
|
...file.data,
|
||||||
challengeFiles
|
challengeFiles
|
||||||
@@ -108,3 +126,4 @@ function validateEditableMarkers({ value, position }) {
|
|||||||
|
|
||||||
module.exports = addSeeds;
|
module.exports = addSeeds;
|
||||||
module.exports.editableRegionMarker = editableRegionMarker;
|
module.exports.editableRegionMarker = editableRegionMarker;
|
||||||
|
module.exports.isWorkshop = isWorkshop;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
const { isEmpty } = require('lodash');
|
const { isEmpty } = require('lodash');
|
||||||
const { root } = require('mdast-builder');
|
const { root } = require('mdast-builder');
|
||||||
const visitChildren = require('unist-util-visit-children');
|
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 { getSection } = require('./utils/get-section');
|
||||||
const { getFileVisitor } = require('./utils/get-file-visitor');
|
const { getFileVisitor } = require('./utils/get-file-visitor');
|
||||||
const { splitOnThematicBreak } = require('./utils/split-on-thematic-break');
|
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() {
|
function createPlugin() {
|
||||||
return function transformer(tree, file) {
|
return function transformer(tree, file) {
|
||||||
const solutionArrays = splitOnThematicBreak(
|
const solutionArrays = splitOnThematicBreak(
|
||||||
@@ -34,6 +82,30 @@ function createPlugin() {
|
|||||||
if (!isEmpty(solution)) solutions.push(Object.values(solution));
|
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 = {
|
||||||
...file.data,
|
...file.data,
|
||||||
solutions: solutions
|
solutions: solutions
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from 'path';
|
||||||
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
|
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
|
||||||
import { isObject } from 'lodash';
|
import { isObject } from 'lodash';
|
||||||
import parseFixture from '../__fixtures__/parse-fixture';
|
import parseFixture from '../__fixtures__/parse-fixture';
|
||||||
@@ -94,4 +95,33 @@ describe('add solution plugin', () => {
|
|||||||
plugin(mockAST, file);
|
plugin(mockAST, file);
|
||||||
expect(file.data).toMatchSnapshot();
|
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