feat(parser): enforce workshop file validations (#66340)

Co-authored-by: sembauke <semboot699@gmail.com>
This commit is contained in:
Harshith Kumar
2026-04-25 11:42:44 +05:30
committed by GitHub
parent 09b97874fc
commit e9e0aac857
9 changed files with 204 additions and 1 deletions
@@ -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" }
]
}