Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions apps/guide/__tests__/GuideLessonFrontmatter_.test.res
Original file line number Diff line number Diff line change
@@ -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"],
}),
),
)
})
7 changes: 6 additions & 1 deletion apps/guide/app/GuideHomeRoute.res
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ type loaderData = {
lessons: array<GuideLesson.t>,
}

exception LessonLoadError(string)

let loader: ReactRouter.Loader.t<loaderData> = async _ => {
let compilerData = await GuideCompilerData.load()
let lessons = GuideLessonContent.load()
let lessons = switch GuideLessonContent.load() {
| Ok(lessons) => lessons
| Error(message) => throw(LessonLoadError(message))
Comment thread
jderochervlk marked this conversation as resolved.
Resolved
}

{compilerData, lessons}
}
Expand Down
80 changes: 17 additions & 63 deletions apps/guide/app/GuideLessonContent.res
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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)
147 changes: 147 additions & 0 deletions apps/guide/app/GuideLessonFrontmatter.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
type duplicate = {
value: string,
sourcePaths: array<string>,
}

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<GuideLesson.t>) =>
lessons->Array.map(lesson => {value: lesson.id, sourcePath: lesson.sourcePath})

let exerciseIds = (lessons: array<GuideLesson.t>) =>
lessons->Array.map(lesson => {value: lesson.exercise.id, sourcePath: lesson.sourcePath})

let positions = (lessons: array<GuideLesson.t>) =>
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
}
Loading