diff --git a/apps/guide/__tests__/GuideLessonFrontmatter_.test.res b/apps/guide/__tests__/GuideLessonFrontmatter_.test.res new file mode 100644 index 000000000..a68607a34 --- /dev/null +++ b/apps/guide/__tests__/GuideLessonFrontmatter_.test.res @@ -0,0 +1,139 @@ +open Vitest + +let sourcePath = "test/lesson.mdx" + +let completeLesson = `--- +position: 1 +id: test-lesson +missionLabel: Test mission +title: Test lesson +description: A test lesson. +exercise: + id: test-lesson/example + title: Test exercise + initialCode: | + let answer = 42 + expectedOutput: "42" +--- + +Test body.` + +let completeLessonResult = () => + switch GuideLessonFrontmatter.parse(~raw=completeLesson, ~sourcePath) { + | Ok(lesson) => lesson + | Error(message) => throw(Failure(message)) + } + +test("parses complete guide lesson frontmatter", async () => { + expect(GuideLessonFrontmatter.parse(~raw=completeLesson, ~sourcePath))->toEqual( + Ok({ + GuideLesson.id: "test-lesson", + position: 1, + sourcePath, + missionLabel: "Test mission", + title: "Test lesson", + description: "A test lesson.", + content: "Test body.", + exercise: { + id: "test-lesson/example", + title: "Test exercise", + initialCode: "let answer = 42", + check: ExpectedOutput("42"), + }, + }), + ) +}) + +test("rejects guide lesson frontmatter without a lesson id", async () => { + let invalidLesson = completeLesson->String.replace("id: test-lesson\n", "") + + expect(GuideLessonFrontmatter.parse(~raw=invalidLesson, ~sourcePath))->toEqual( + Error(`Guide lesson ${sourcePath} frontmatter "id" must be a non-empty string.`), + ) +}) + +test("collects parsed guide lessons", async () => { + let firstLesson = completeLessonResult() + let secondLesson = { + ...firstLesson, + id: "second-lesson", + sourcePath: "test/second-lesson.mdx", + exercise: {...firstLesson.exercise, id: "second-lesson/example"}, + } + + expect(GuideLessonContent.collect([Ok(firstLesson), Ok(secondLesson)]))->toEqual( + Ok([firstLesson, secondLesson]), + ) +}) + +test("preserves the first frontmatter error while collecting lessons", async () => { + let firstLesson = completeLessonResult() + + expect( + GuideLessonContent.collect([Ok(firstLesson), Error("first error"), Error("second error")]), + )->toEqual(Error("first error")) +}) + +test("accepts a guide lesson collection with distinct identifiers and positions", async () => { + let firstLesson = completeLessonResult() + let secondLesson = { + ...firstLesson, + id: "second-lesson", + position: 2, + sourcePath: "test/second-lesson.mdx", + exercise: {...firstLesson.exercise, id: "second-lesson/example"}, + } + + expect(GuideLessonFrontmatter.validate([firstLesson, secondLesson]))->toEqual(Ok()) +}) + +test("rejects duplicate guide lesson ids", async () => { + let firstLesson = completeLessonResult() + let secondLesson = {...firstLesson, sourcePath: "test/second-lesson.mdx"} + + expect(GuideLessonFrontmatter.validate([firstLesson, secondLesson]))->toEqual( + Error( + GuideLessonFrontmatter.DuplicateLessonId({ + value: "test-lesson", + sourcePaths: ["test/lesson.mdx", "test/second-lesson.mdx"], + }), + ), + ) +}) + +test("rejects duplicate guide exercise ids", async () => { + let firstLesson = completeLessonResult() + let secondLesson = { + ...firstLesson, + id: "second-lesson", + sourcePath: "test/second-lesson.mdx", + } + + expect(GuideLessonFrontmatter.validate([firstLesson, secondLesson]))->toEqual( + Error( + GuideLessonFrontmatter.DuplicateExerciseId({ + value: "test-lesson/example", + sourcePaths: ["test/lesson.mdx", "test/second-lesson.mdx"], + }), + ), + ) +}) + +test("rejects duplicate guide lesson positions", async () => { + let firstLesson = completeLessonResult() + let secondLesson = { + ...firstLesson, + id: "second-lesson", + sourcePath: "test/second-lesson.mdx", + exercise: {...firstLesson.exercise, id: "second-lesson/example"}, + } + + expect(GuideLessonFrontmatter.validate([firstLesson, secondLesson]))->toEqual( + Error( + GuideLessonFrontmatter.DuplicatePosition({ + value: "1", + sourcePaths: ["test/lesson.mdx", "test/second-lesson.mdx"], + }), + ), + ) +}) diff --git a/apps/guide/app/GuideHomeRoute.res b/apps/guide/app/GuideHomeRoute.res index 203547e4c..1f36b5179 100644 --- a/apps/guide/app/GuideHomeRoute.res +++ b/apps/guide/app/GuideHomeRoute.res @@ -3,9 +3,14 @@ type loaderData = { lessons: array, } +exception LessonLoadError(string) + let loader: ReactRouter.Loader.t = async _ => { let compilerData = await GuideCompilerData.load() - let lessons = GuideLessonContent.load() + let lessons = switch GuideLessonContent.load() { + | Ok(lessons) => lessons + | Error(message) => throw(LessonLoadError(message)) + } {compilerData, lessons} } diff --git a/apps/guide/app/GuideLessonContent.res b/apps/guide/app/GuideLessonContent.res index 1e5d60e7c..7f64f1383 100644 --- a/apps/guide/app/GuideLessonContent.res +++ b/apps/guide/app/GuideLessonContent.res @@ -1,69 +1,22 @@ -let fail = message => JsExn.throw(Error(message)) - -let fieldLabel = (~sourcePath, ~key) => `Guide lesson ${sourcePath} frontmatter "${key}"` - -let readString = (~dict, ~sourcePath, ~key) => - switch dict->Dict.get(key) { - | Some(JSON.String(value)) if value->String.trim !== "" => value - | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be a non-empty string.`) - } - -let readOptionalString = (~dict, ~sourcePath, ~key) => - switch dict->Dict.get(key) { - | Some(JSON.String(value)) => Some(value) - | Some(_) => fail(`${fieldLabel(~sourcePath, ~key)} must be a string when present.`) - | None => None - } - -let readInt = (~dict, ~sourcePath, ~key) => - switch dict->Dict.get(key) { - | Some(JSON.Number(value)) => value->Float.toInt - | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be a number.`) - } - -let readObject = (~dict, ~sourcePath, ~key) => - switch dict->Dict.get(key) { - | Some(JSON.Object(value)) => value - | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be an object.`) - } - -let frontmatterObject = (~frontmatter, ~sourcePath) => - switch frontmatter { - | JSON.Object(dict) => dict - | _ => fail(`Guide lesson ${sourcePath} must use object frontmatter.`) - } - -let exerciseFromFrontmatter = (~dict, ~sourcePath): GuideLesson.exercise => { - let check = switch readOptionalString(~dict, ~sourcePath, ~key="expectedOutput") { - | Some(expectedOutput) => GuideLesson.ExpectedOutput(expectedOutput) - | None => GuideLesson.Manual - } - - { - id: readString(~dict, ~sourcePath, ~key="id"), - title: readString(~dict, ~sourcePath, ~key="title"), - initialCode: readString(~dict, ~sourcePath, ~key="initialCode")->String.trimEnd, - check, - } -} - let lessonFromFile = sourcePath => { let raw = Node.Fs.readFileSync(sourcePath) - let {frontmatter, content}: MarkdownParser.result = MarkdownParser.parseSync(raw) - let dict = frontmatterObject(~frontmatter, ~sourcePath) - let exerciseDict = readObject(~dict, ~sourcePath, ~key="exercise") + GuideLessonFrontmatter.parse(~raw, ~sourcePath) +} - { - GuideLesson.id: readString(~dict, ~sourcePath, ~key="id"), - position: readInt(~dict, ~sourcePath, ~key="position"), - sourcePath, - missionLabel: readString(~dict, ~sourcePath, ~key="missionLabel"), - title: readString(~dict, ~sourcePath, ~key="title"), - description: readString(~dict, ~sourcePath, ~key="description"), - content: content->String.trim, - exercise: exerciseFromFrontmatter(~dict=exerciseDict, ~sourcePath), +let collect = results => + results->Array.reduce(Ok([]), (accumulator, result) => + switch (accumulator, result) { + | (Ok(lessons), Ok(lesson)) => Ok([...lessons, lesson]) + | (Error(message), _) => Error(message) + | (_, Error(message)) => Error(message) + } + ) + +let validateAndSort = lessons => + switch GuideLessonFrontmatter.validate(lessons) { + | Ok() => Ok(lessons->GuideLesson.sort) + | Error(error) => Error(GuideLessonFrontmatter.validationErrorMessage(error)) } -} let rec scanDir = currentDir => Node.Fs.readdirSync(currentDir)->Array.flatMap(entry => { @@ -80,4 +33,5 @@ let rec scanDir = currentDir => let lessonsDir = () => Node.Path.join2(Node.Process.cwd(), "app/lessons") -let load = (~dir=lessonsDir()) => scanDir(dir)->Array.map(lessonFromFile)->GuideLesson.sort +let load = (~dir=lessonsDir()) => + scanDir(dir)->Array.map(lessonFromFile)->collect->Result.flatMap(validateAndSort) diff --git a/apps/guide/app/GuideLessonFrontmatter.res b/apps/guide/app/GuideLessonFrontmatter.res new file mode 100644 index 000000000..06efece83 --- /dev/null +++ b/apps/guide/app/GuideLessonFrontmatter.res @@ -0,0 +1,147 @@ +type duplicate = { + value: string, + sourcePaths: array, +} + +type validationError = + | DuplicateLessonId(duplicate) + | DuplicateExerciseId(duplicate) + | DuplicatePosition(duplicate) + +type identifiedValue = { + value: string, + sourcePath: string, +} + +exception InvalidFrontmatter(string) + +let fail = message => throw(InvalidFrontmatter(message)) + +let fieldLabel = (~sourcePath, ~key) => `Guide lesson ${sourcePath} frontmatter "${key}"` + +let readString = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.String(value)) if value->String.trim !== "" => value + | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be a non-empty string.`) + } + +let readOptionalString = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.String(value)) => Some(value) + | Some(_) => fail(`${fieldLabel(~sourcePath, ~key)} must be a string when present.`) + | None => None + } + +let readInt = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.Number(value)) => value->Float.toInt + | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be a number.`) + } + +let readObject = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.Object(value)) => value + | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be an object.`) + } + +let frontmatterObject = (~frontmatter, ~sourcePath) => + switch frontmatter { + | JSON.Object(dict) => dict + | _ => fail(`Guide lesson ${sourcePath} must use object frontmatter.`) + } + +let exerciseFromFrontmatter = (~dict, ~sourcePath): GuideLesson.exercise => { + let check = switch readOptionalString(~dict, ~sourcePath, ~key="expectedOutput") { + | Some(expectedOutput) => GuideLesson.ExpectedOutput(expectedOutput) + | None => GuideLesson.Manual + } + + { + id: readString(~dict, ~sourcePath, ~key="id"), + title: readString(~dict, ~sourcePath, ~key="title"), + initialCode: readString(~dict, ~sourcePath, ~key="initialCode")->String.trimEnd, + check, + } +} + +let fromRaw = (~raw, ~sourcePath) => { + let {frontmatter, content}: MarkdownParser.result = MarkdownParser.parseSync(raw) + let dict = frontmatterObject(~frontmatter, ~sourcePath) + let exerciseDict = readObject(~dict, ~sourcePath, ~key="exercise") + + { + GuideLesson.id: readString(~dict, ~sourcePath, ~key="id"), + position: readInt(~dict, ~sourcePath, ~key="position"), + sourcePath, + missionLabel: readString(~dict, ~sourcePath, ~key="missionLabel"), + title: readString(~dict, ~sourcePath, ~key="title"), + description: readString(~dict, ~sourcePath, ~key="description"), + content: content->String.trim, + exercise: exerciseFromFrontmatter(~dict=exerciseDict, ~sourcePath), + } +} + +let parse = (~raw, ~sourcePath) => + try { + Ok(fromRaw(~raw, ~sourcePath)) + } catch { + | InvalidFrontmatter(message) => Error(message) + | JsExn(error) => + Error( + error + ->JsExn.message + ->Option.getOr(`Guide lesson ${sourcePath} could not parse frontmatter.`), + ) + } + +let findDuplicate = values => + values->Array.findMap(current => + values + ->Array.find(other => other.sourcePath !== current.sourcePath && other.value === current.value) + ->Option.map(other => { + value: current.value, + sourcePaths: [current.sourcePath, other.sourcePath], + }) + ) + +let lessonIds = (lessons: array) => + lessons->Array.map(lesson => {value: lesson.id, sourcePath: lesson.sourcePath}) + +let exerciseIds = (lessons: array) => + lessons->Array.map(lesson => {value: lesson.exercise.id, sourcePath: lesson.sourcePath}) + +let positions = (lessons: array) => + lessons->Array.map(lesson => { + value: lesson.position->Int.toString, + sourcePath: lesson.sourcePath, + }) + +let validate = lessons => + switch lessons->lessonIds->findDuplicate { + | Some(duplicate) => Error(DuplicateLessonId(duplicate)) + | None => + switch lessons->exerciseIds->findDuplicate { + | Some(duplicate) => Error(DuplicateExerciseId(duplicate)) + | None => + switch lessons->positions->findDuplicate { + | Some(duplicate) => Error(DuplicatePosition(duplicate)) + | None => Ok() + } + } + } + +let validationErrorMessage = error => + switch error { + | DuplicateLessonId({value, sourcePaths}) => + `Duplicate guide lesson id "${value}" in ${sourcePaths->Array.join(" and ")}.` + | DuplicateExerciseId({value, sourcePaths}) => + `Duplicate guide exercise id "${value}" in ${sourcePaths->Array.join(" and ")}.` + | DuplicatePosition({value, sourcePaths}) => + `Duplicate guide lesson position "${value}" in ${sourcePaths->Array.join(" and ")}.` + } + +let validateOrFail = lessons => + switch lessons->validate { + | Ok() => lessons + | Error(error) => error->validationErrorMessage->fail + }