diff --git a/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-1.md b/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-1.md new file mode 100644 index 00000000000..57c9f78acc2 --- /dev/null +++ b/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-1.md @@ -0,0 +1,19 @@ +--- +id: abc123 +title: Step 1 +challengeType: 28 +--- + +# --description-- + +Some description. + +# --hints-- +```js +assert(true); +``` + +# --solutions-- +```js +var x = 1; +``` diff --git a/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-2.md b/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-2.md new file mode 100644 index 00000000000..d3cd7898079 --- /dev/null +++ b/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-2.md @@ -0,0 +1,14 @@ +--- +id: abc456 +title: Step 2 +challengeType: 28 +--- + +# --description-- + +Some description. + +# --hints-- +```js +assert(true); +``` diff --git a/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-with-four-erms.md b/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-with-four-erms.md new file mode 100644 index 00000000000..f37a8e1a1fa --- /dev/null +++ b/tools/challenge-parser/parser/__fixtures__/workshop-test-steps/step-with-four-erms.md @@ -0,0 +1,22 @@ +--- +id: abc789 +title: Step 1 +challengeType: 28 +--- + +# --description-- + +Some description. + +# --seed-- + +## --seed-contents-- +```html +--fcc-editable-region-- +
First region
+--fcc-editable-region-- +Middle
+--fcc-editable-region-- +Second region
+--fcc-editable-region-- +``` diff --git a/tools/challenge-parser/parser/__fixtures__/workshop-upcoming-test-steps/step-1.md b/tools/challenge-parser/parser/__fixtures__/workshop-upcoming-test-steps/step-1.md new file mode 100644 index 00000000000..b618811c2d8 --- /dev/null +++ b/tools/challenge-parser/parser/__fixtures__/workshop-upcoming-test-steps/step-1.md @@ -0,0 +1,9 @@ +--- +id: step-1 +title: Step 1 +challengeType: 28 +--- + +# --description-- + +Placeholder fixture for parser path resolution tests. diff --git a/tools/challenge-parser/parser/plugins/add-seed.js b/tools/challenge-parser/parser/plugins/add-seed.js index 9425ec74d0d..6799ae7a8a5 100644 --- a/tools/challenge-parser/parser/plugins/add-seed.js +++ b/tools/challenge-parser/parser/plugins/add-seed.js @@ -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; diff --git a/tools/challenge-parser/parser/plugins/add-solution.js b/tools/challenge-parser/parser/plugins/add-solution.js index 4d7e82ba0ce..14c2d5ec25d 100644 --- a/tools/challenge-parser/parser/plugins/add-solution.js +++ b/tools/challenge-parser/parser/plugins/add-solution.js @@ -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 diff --git a/tools/challenge-parser/parser/plugins/add-solution.test.js b/tools/challenge-parser/parser/plugins/add-solution.test.js index 56aef7b3df5..d111bb8eeea 100644 --- a/tools/challenge-parser/parser/plugins/add-solution.test.js +++ b/tools/challenge-parser/parser/plugins/add-solution.test.js @@ -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(); + }); }); diff --git a/tools/challenge-parser/structure/blocks/workshop-test-steps.json b/tools/challenge-parser/structure/blocks/workshop-test-steps.json new file mode 100644 index 00000000000..8fc6faeafeb --- /dev/null +++ b/tools/challenge-parser/structure/blocks/workshop-test-steps.json @@ -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" } + ] +} diff --git a/tools/challenge-parser/structure/blocks/workshop-upcoming-test-steps.json b/tools/challenge-parser/structure/blocks/workshop-upcoming-test-steps.json new file mode 100644 index 00000000000..8719061de05 --- /dev/null +++ b/tools/challenge-parser/structure/blocks/workshop-upcoming-test-steps.json @@ -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" } + ] +}