From f5aaf1d6b52736ea0b13a4a865eff5dd004144f4 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 13 Sep 2026 10:33:23 -0400 Subject: [PATCH 1/2] feat(guide): validate lesson collections Validate guide lesson IDs, exercise IDs, and positions during content loading. Extract parsing into a testable frontmatter boundary with focused coverage for valid and invalid lesson collections. --- .../GuideLessonFrontmatter_.test.res | 117 ++++++++++++++ apps/guide/app/GuideLessonContent.res | 68 +------- apps/guide/app/GuideLessonFrontmatter.res | 147 ++++++++++++++++++ 3 files changed, 269 insertions(+), 63 deletions(-) create mode 100644 apps/guide/__tests__/GuideLessonFrontmatter_.test.res create mode 100644 apps/guide/app/GuideLessonFrontmatter.res diff --git a/apps/guide/__tests__/GuideLessonFrontmatter_.test.res b/apps/guide/__tests__/GuideLessonFrontmatter_.test.res new file mode 100644 index 000000000..27d3c1c6d --- /dev/null +++ b/apps/guide/__tests__/GuideLessonFrontmatter_.test.res @@ -0,0 +1,117 @@ +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("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/GuideLessonContent.res b/apps/guide/app/GuideLessonContent.res index 1e5d60e7c..b9ccd9766 100644 --- a/apps/guide/app/GuideLessonContent.res +++ b/apps/guide/app/GuideLessonContent.res @@ -1,67 +1,8 @@ -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") - - { - 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), + switch GuideLessonFrontmatter.parse(~raw, ~sourcePath) { + | Ok(lesson) => lesson + | Error(message) => throw(GuideLessonFrontmatter.InvalidFrontmatter(message)) } } @@ -80,4 +21,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)->GuideLessonFrontmatter.validateOrFail->GuideLesson.sort 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 + } From 4a849b45a6ceb916a38f82d57317b401efee5e21 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 13 Sep 2026 17:19:28 -0400 Subject: [PATCH 2/2] fix(guide): propagate lesson parse errors Keep frontmatter parsing failures as result values until the route loader exception boundary. --- .../GuideLessonFrontmatter_.test.res | 22 +++++++++++++++++++ apps/guide/app/GuideHomeRoute.res | 7 +++++- apps/guide/app/GuideLessonContent.res | 22 ++++++++++++++----- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/guide/__tests__/GuideLessonFrontmatter_.test.res b/apps/guide/__tests__/GuideLessonFrontmatter_.test.res index 27d3c1c6d..a68607a34 100644 --- a/apps/guide/__tests__/GuideLessonFrontmatter_.test.res +++ b/apps/guide/__tests__/GuideLessonFrontmatter_.test.res @@ -52,6 +52,28 @@ test("rejects guide lesson frontmatter without a lesson id", async () => { ) }) +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 = { 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 b9ccd9766..7f64f1383 100644 --- a/apps/guide/app/GuideLessonContent.res +++ b/apps/guide/app/GuideLessonContent.res @@ -1,11 +1,23 @@ let lessonFromFile = sourcePath => { let raw = Node.Fs.readFileSync(sourcePath) - switch GuideLessonFrontmatter.parse(~raw, ~sourcePath) { - | Ok(lesson) => lesson - | Error(message) => throw(GuideLessonFrontmatter.InvalidFrontmatter(message)) - } + GuideLessonFrontmatter.parse(~raw, ~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 => { let fullPath = Node.Path.join2(currentDir, entry) @@ -22,4 +34,4 @@ let rec scanDir = currentDir => let lessonsDir = () => Node.Path.join2(Node.Process.cwd(), "app/lessons") let load = (~dir=lessonsDir()) => - scanDir(dir)->Array.map(lessonFromFile)->GuideLessonFrontmatter.validateOrFail->GuideLesson.sort + scanDir(dir)->Array.map(lessonFromFile)->collect->Result.flatMap(validateAndSort)