feat(course-format): stcourse.yaml schema
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3b15d87a5f
commit
7e7ce30535
@@ -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": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||
|
||||
"zod": ["[email protected]", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||
|
||||
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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}`) };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user