112 lines
3.9 KiB
TypeScript
112 lines
3.9 KiB
TypeScript
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<string>();
|
|
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<typeof ManifestSchema>;
|
|
export type Module = z.infer<typeof Module>;
|
|
export type Lesson = z.infer<typeof Lesson>;
|
|
export type Deck = z.infer<typeof Deck>;
|
|
export type Attachment = z.infer<typeof Attachment>;
|
|
export type Track = z.infer<typeof Track>;
|
|
|
|
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}`) };
|
|
}
|