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}`) }; }