diff --git a/bun.lock b/bun.lock index d461254..736e0ae 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,9 @@ "packages/course-format": { "name": "@stc/course-format", "version": "0.0.0", + "dependencies": { + "zod": "^4.6.7", + }, }, "packages/db": { "name": "@stc/db", @@ -90,6 +93,8 @@ "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], } } diff --git a/packages/course-format/package.json b/packages/course-format/package.json index bbe81b8..935c4e7 100644 --- a/packages/course-format/package.json +++ b/packages/course-format/package.json @@ -3,5 +3,8 @@ "version": "0.0.0", "private": true, "type": "module", - "license": "AGPL-3.0-only" + "license": "AGPL-3.0-only", + "dependencies": { + "zod": "^4.6.7" + } } diff --git a/packages/course-format/src/schema.ts b/packages/course-format/src/schema.ts new file mode 100644 index 0000000..b488cd7 --- /dev/null +++ b/packages/course-format/src/schema.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +const Track = z.object({ id: z.string(), label: z.string() }); +const Deck = z.object({ + id: z.string(), + file: z.string(), + title: z.string().optional(), + attachTo: z.string(), // a lesson id or module id +}); +const Attachment = z.object({ file: z.string(), title: z.string().optional(), module: z.string().optional() }); + +const Lesson = z.object({ + id: z.string(), + title: z.string(), + type: z.enum(["text", "video", "exercise"]), + file: z.string().optional(), + anchor: z.string().optional(), + duration: z.number().optional(), // seconds + transcript: z.string().optional(), + tracks: z.record(z.string(), z.object({ file: z.string().optional(), command: z.string().optional() })).optional(), + hints: z.object({ file: z.string(), match: z.string() }).optional(), + browser: z + .object({ + language: z.enum(["js", "ts"]), + starter: z.string(), + tests: z.string(), + solution: z.string().optional(), + }) + .optional(), + localCommand: z.union([z.string(), z.record(z.string(), z.string())]).optional(), +}); + +const Module = z.object({ + id: z.string(), + title: z.string(), + code: z.object({ dir: z.string(), command: z.string().optional() }).optional(), + lessons: z.array(Lesson), +}); + +export const ManifestSchema = z + .object({ + schema: z.literal(1), + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + pitch: z.string().optional(), + authors: z.array(z.string()).optional(), + root: z.string().optional(), + cover: z.string().optional(), + accent: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), + collections: z.array(z.string()).optional(), + visibility: z.enum(["public", "signed-in", "restricted"]).default("restricted"), + gating: z.enum(["none", "sequential"]).default("none"), + tracks: z.array(Track).optional(), + mentor: z.string().optional(), + source: z + .object({ + kind: z.enum(["authored", "packt-video", "pdf-deck", "code-repo", "video-folder"]), + origin: z.string().optional(), + importedAt: z.string().optional(), + }) + .optional(), + decks: z.array(Deck).optional(), + attachments: z.array(Attachment).optional(), + modules: z.array(Module), + }) + .superRefine((m, ctx) => { + const lessonIds = new Set(); + const moduleIds = new Set(m.modules.map((x) => x.id)); + m.modules.forEach((mod, mi) => { + mod.lessons.forEach((l, li) => { + const path = ["modules", mi, "lessons", li]; + if (lessonIds.has(l.id)) { + ctx.addIssue({ code: "custom", path: [...path, "id"], message: `duplicate lesson id "${l.id}"` }); + } + lessonIds.add(l.id); + if ((l.type === "text" || l.type === "video") && !l.file) { + ctx.addIssue({ code: "custom", path: [...path, "file"], message: `file is required for ${l.type} lessons` }); + } + }); + }); + m.decks?.forEach((d, i) => { + if (!lessonIds.has(d.attachTo) && !moduleIds.has(d.attachTo)) { + ctx.addIssue({ + code: "custom", + path: ["decks", i, "attachTo"], + message: `attachTo "${d.attachTo}" is not a lesson or module id`, + }); + } + }); + }); + +export type Manifest = z.infer; +export type Module = z.infer; +export type Lesson = z.infer; +export type Deck = z.infer; +export type Attachment = z.infer; +export type Track = z.infer; + +export function parseManifest( + yamlText: string, +): { ok: true; manifest: Manifest } | { ok: false; errors: string[] } { + let raw: unknown; + try { + raw = Bun.YAML.parse(yamlText); + } catch (e) { + return { ok: false, errors: [`yaml: ${e instanceof Error ? e.message : String(e)}`] }; + } + const r = ManifestSchema.safeParse(raw); + if (r.success) return { ok: true, manifest: r.data }; + return { ok: false, errors: r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) }; +} diff --git a/packages/course-format/test/schema.test.ts b/packages/course-format/test/schema.test.ts new file mode 100644 index 0000000..16a64fa --- /dev/null +++ b/packages/course-format/test/schema.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { parseManifest } from "../src/schema"; + +const base = (extra = "", lessons = " - { id: l1, title: One, type: text, file: a.md }") => + `schema: 1\nid: x\ntitle: X\n${extra}modules:\n - id: m1\n title: M\n lessons:\n${lessons}\n`; + +describe("parseManifest", () => { + test("parses a minimal manifest", () => { + const r = parseManifest(base()); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.manifest.visibility).toBe("restricted"); + expect(r.manifest.gating).toBe("none"); + } + }); + + test("rejects a bad accent", () => { + const r = parseManifest(base('accent: "orange"\n')); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors.some((e) => e.includes("accent"))).toBe(true); + }); + + test("rejects duplicate lesson ids across modules", () => { + const text = base() + " - id: m2\n title: M2\n lessons:\n - { id: l1, title: Dup, type: text, file: b.md }\n"; + const r = parseManifest(text); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors.some((e) => e.includes("l1"))).toBe(true); + }); + + test("requires file for text and video lessons", () => { + for (const type of ["text", "video"]) { + const r = parseManifest(base("", ` - { id: l1, title: One, type: ${type} }`)); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors.some((e) => e.includes("file"))).toBe(true); + } + expect(parseManifest(base("", " - { id: l1, title: One, type: exercise }")).ok).toBe(true); + }); + + test("rejects a deck whose attachTo is not a lesson or module id", () => { + const r = parseManifest(base("decks:\n - { id: d1, file: d.pdf, attachTo: nope }\n")); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors.some((e) => e.includes("nope"))).toBe(true); + expect(parseManifest(base("decks:\n - { id: d1, file: d.pdf, attachTo: m1 }\n")).ok).toBe(true); + }); + + test("rejects schema: 2", () => { + const r = parseManifest(base().replace("schema: 1", "schema: 2")); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors.some((e) => e.startsWith("schema"))).toBe(true); + }); +});