@datajaddah/course 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/bin/datajaddah-course.js +238 -0
- package/lib/init.js +375 -0
- package/lib/preview.js +1617 -0
- package/lib/repo-contract.js +1291 -0
- package/lib/slide-markdown.js +505 -0
- package/package.json +41 -0
- package/preview-dist/assets/index-CJUgarn8.css +1 -0
- package/preview-dist/assets/index-CavDNP3d.js +49 -0
- package/preview-dist/index.html +13 -0
- package/scaffold/.agents/rules/general.md +91 -0
- package/scaffold/.agents/skills/course-coding-exercises/SKILL.md +22 -0
- package/scaffold/.agents/skills/course-coding-exercises/references/coding-exercises.md +111 -0
- package/scaffold/.agents/skills/course-platform-overview/SKILL.md +36 -0
- package/scaffold/.agents/skills/course-platform-overview/references/platform-overview.md +105 -0
- package/scaffold/.agents/skills/course-quizzes/SKILL.md +23 -0
- package/scaffold/.agents/skills/course-quizzes/references/quizzes.md +121 -0
- package/scaffold/.agents/skills/course-repo-contract/SKILL.md +24 -0
- package/scaffold/.agents/skills/course-repo-contract/references/repo-contract.md +169 -0
- package/scaffold/.agents/skills/course-slides-v2/SKILL.md +28 -0
- package/scaffold/.agents/skills/course-slides-v2/references/fit-guidance.md +31 -0
- package/scaffold/.agents/skills/course-slides-v2/references/slides-v2.md +138 -0
- package/scaffold/.agents/skills/course-spreadsheet-labs/SKILL.md +23 -0
- package/scaffold/.agents/skills/course-spreadsheet-labs/references/spreadsheet-labs.md +239 -0
- package/scaffold/.agents/skills/course-video-lessons/SKILL.md +22 -0
- package/scaffold/.agents/skills/course-video-lessons/references/video-lessons.md +83 -0
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { renderSlideMarkdown } from "./slide-markdown.js";
|
|
4
|
+
|
|
5
|
+
const ORDERED_NAME_PATTERN = /^(?<order>\d+)-(?<slug>[a-z0-9][a-z0-9-]*)$/;
|
|
6
|
+
const FRONTMATTER_KEY_PATTERN = /^(?<key>[A-Za-z_][A-Za-z0-9_]*):(?:[ \t]*(?<value>.*))?$/;
|
|
7
|
+
const CHOICE_METADATA_PATTERN = /^(?<key>[a-z_][a-z0-9_]*):(?:[ \t]*(?<value>.*))?$/;
|
|
8
|
+
const FRONTMATTER_LIST_ITEM_PATTERN = /^(?:[ \t]*)-\s+(?<value>.*)$/;
|
|
9
|
+
const MARKDOWN_HEADING_PATTERN = /^(#{2,6})\s+(.+?)\s*$/;
|
|
10
|
+
const PLACEHOLDER_VIDEO_URL_HOSTS = new Set([
|
|
11
|
+
"example.com",
|
|
12
|
+
"www.example.com",
|
|
13
|
+
"example.org",
|
|
14
|
+
"www.example.org",
|
|
15
|
+
"example.net",
|
|
16
|
+
"www.example.net",
|
|
17
|
+
]);
|
|
18
|
+
const PLACEHOLDER_VIDEO_URL_PATTERNS = [/replace[-_]?me/i, /placeholder/i];
|
|
19
|
+
|
|
20
|
+
const CHOICE_START = "@choice";
|
|
21
|
+
const CHOICE_END = "@endchoice";
|
|
22
|
+
const SCRIPT_START = "@script";
|
|
23
|
+
const SCRIPT_END = "@endscript";
|
|
24
|
+
const SUPPORTED_SPREADSHEET_ENGINES = new Set(["univer"]);
|
|
25
|
+
const SPREADSHEET_CHECK_TYPES = new Set([
|
|
26
|
+
"value_equals",
|
|
27
|
+
"text_equals",
|
|
28
|
+
"has_formula",
|
|
29
|
+
"formula_equals",
|
|
30
|
+
"one_of",
|
|
31
|
+
"value_with_tolerance",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export class RepoContractError extends Error {
|
|
35
|
+
constructor(filePath, message, line = 1) {
|
|
36
|
+
super(`${filePath}:${line}: ${message}`);
|
|
37
|
+
this.name = "RepoContractError";
|
|
38
|
+
this.path = filePath;
|
|
39
|
+
this.line = line;
|
|
40
|
+
this.message = message;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toPosixPath(filePath) {
|
|
45
|
+
return filePath.split(path.sep).join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function relativePosix(rootPath, filePath) {
|
|
49
|
+
return toPosixPath(path.relative(rootPath, filePath));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function slugToTitle(slug) {
|
|
53
|
+
return slug
|
|
54
|
+
.split("-")
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
57
|
+
.join(" ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeInlineText(value) {
|
|
61
|
+
return String(value || "")
|
|
62
|
+
.replace(/\s+/g, " ")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function plainTextFromMarkdown(value) {
|
|
67
|
+
return normalizeInlineText(
|
|
68
|
+
String(value || "")
|
|
69
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
70
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
71
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
72
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
73
|
+
.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1$2")
|
|
74
|
+
.replace(/_{1,2}([^_]+)_{1,2}/g, "$1")
|
|
75
|
+
.replace(/<\/?[^>]+>/g, " ")
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function orderedPathParts(filePath) {
|
|
80
|
+
const baseName = path.basename(filePath, path.extname(filePath));
|
|
81
|
+
const match = ORDERED_NAME_PATTERN.exec(baseName);
|
|
82
|
+
if (!match?.groups) {
|
|
83
|
+
throw new RepoContractError(
|
|
84
|
+
toPosixPath(filePath),
|
|
85
|
+
"Expected an ordered name like `01-intro` or `01-question.md`"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
order: Number(match.groups.order),
|
|
90
|
+
slug: match.groups.slug,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ensureString(value, filePath, line, label, { required = false } = {}) {
|
|
95
|
+
if (value == null) {
|
|
96
|
+
if (required) {
|
|
97
|
+
throw new RepoContractError(toPosixPath(filePath), `${label} is required`, line);
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (typeof value !== "string") {
|
|
102
|
+
throw new RepoContractError(toPosixPath(filePath), `${label} must be a string`, line);
|
|
103
|
+
}
|
|
104
|
+
const normalized = value.trim();
|
|
105
|
+
if (required && !normalized) {
|
|
106
|
+
throw new RepoContractError(toPosixPath(filePath), `${label} is required`, line);
|
|
107
|
+
}
|
|
108
|
+
return normalized || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ensureBoolean(value, filePath, line, label, { defaultValue = false } = {}) {
|
|
112
|
+
if (value == null) {
|
|
113
|
+
return defaultValue;
|
|
114
|
+
}
|
|
115
|
+
if (typeof value !== "boolean") {
|
|
116
|
+
throw new RepoContractError(toPosixPath(filePath), `${label} must be a boolean`, line);
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureInteger(value, filePath, line, label) {
|
|
122
|
+
if (value == null) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (!Number.isInteger(value)) {
|
|
126
|
+
throw new RepoContractError(toPosixPath(filePath), `${label} must be an integer`, line);
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureVideoUrl(value, filePath, line) {
|
|
132
|
+
const normalized = ensureString(value, filePath, line, "video_url", { required: true });
|
|
133
|
+
let parsedUrl;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
parsedUrl = new URL(normalized);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new RepoContractError(
|
|
139
|
+
toPosixPath(filePath),
|
|
140
|
+
"video_url must be an absolute http(s) URL",
|
|
141
|
+
line
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
146
|
+
throw new RepoContractError(
|
|
147
|
+
toPosixPath(filePath),
|
|
148
|
+
"video_url must use `http` or `https`",
|
|
149
|
+
line
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
PLACEHOLDER_VIDEO_URL_HOSTS.has(parsedUrl.hostname.toLowerCase()) ||
|
|
155
|
+
PLACEHOLDER_VIDEO_URL_PATTERNS.some((pattern) => pattern.test(normalized))
|
|
156
|
+
) {
|
|
157
|
+
throw new RepoContractError(
|
|
158
|
+
toPosixPath(filePath),
|
|
159
|
+
"video_url must be a real external video URL, not a placeholder",
|
|
160
|
+
line
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return normalized;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseExplicitBoolean(value, filePath, line, label) {
|
|
168
|
+
if (typeof value === "boolean") {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
if (typeof value === "string") {
|
|
172
|
+
const normalized = value.trim().toLowerCase();
|
|
173
|
+
if (normalized === "true") {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (normalized === "false") {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new RepoContractError(toPosixPath(filePath), `${label} must be \`true\` or \`false\``, line);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseInlineArray(value) {
|
|
184
|
+
const inner = value.slice(1, -1).trim();
|
|
185
|
+
if (!inner) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const items = [];
|
|
190
|
+
let current = "";
|
|
191
|
+
let quote = null;
|
|
192
|
+
|
|
193
|
+
for (let index = 0; index < inner.length; index += 1) {
|
|
194
|
+
const char = inner[index];
|
|
195
|
+
if (quote) {
|
|
196
|
+
current += char;
|
|
197
|
+
if (char === quote && inner[index - 1] !== "\\") {
|
|
198
|
+
quote = null;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (char === "'" || char === '"') {
|
|
203
|
+
quote = char;
|
|
204
|
+
current += char;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (char === ",") {
|
|
208
|
+
items.push(parseScalar(current.trim()));
|
|
209
|
+
current = "";
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
current += char;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (current.trim()) {
|
|
216
|
+
items.push(parseScalar(current.trim()));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return items;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseScalar(rawValue) {
|
|
223
|
+
const value = rawValue.trim();
|
|
224
|
+
if (!value) {
|
|
225
|
+
return "";
|
|
226
|
+
}
|
|
227
|
+
if (value === "true") {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (value === "false") {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (/^-?\d+$/.test(value)) {
|
|
234
|
+
return Number(value);
|
|
235
|
+
}
|
|
236
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
237
|
+
return parseInlineArray(value);
|
|
238
|
+
}
|
|
239
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(value);
|
|
242
|
+
} catch {
|
|
243
|
+
return value.slice(1, -1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
247
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
248
|
+
}
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseFrontmatter(frontmatterLines, filePath) {
|
|
253
|
+
const frontmatter = {};
|
|
254
|
+
let index = 0;
|
|
255
|
+
|
|
256
|
+
while (index < frontmatterLines.length) {
|
|
257
|
+
const rawLine = frontmatterLines[index];
|
|
258
|
+
const lineNumber = index + 2;
|
|
259
|
+
|
|
260
|
+
if (!rawLine.trim()) {
|
|
261
|
+
index += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (/^[ \t]/.test(rawLine)) {
|
|
266
|
+
throw new RepoContractError(
|
|
267
|
+
toPosixPath(filePath),
|
|
268
|
+
"Frontmatter entries must start at column 1",
|
|
269
|
+
lineNumber
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const match = FRONTMATTER_KEY_PATTERN.exec(rawLine);
|
|
274
|
+
if (!match?.groups) {
|
|
275
|
+
throw new RepoContractError(
|
|
276
|
+
toPosixPath(filePath),
|
|
277
|
+
"Invalid frontmatter entry; expected `key: value`",
|
|
278
|
+
lineNumber
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const key = match.groups.key;
|
|
283
|
+
if (Object.prototype.hasOwnProperty.call(frontmatter, key)) {
|
|
284
|
+
throw new RepoContractError(
|
|
285
|
+
toPosixPath(filePath),
|
|
286
|
+
`Duplicate frontmatter field \`${key}\``,
|
|
287
|
+
lineNumber
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const rawValue = (match.groups.value ?? "").trimEnd();
|
|
292
|
+
if (rawValue === "|") {
|
|
293
|
+
const blockLines = [];
|
|
294
|
+
index += 1;
|
|
295
|
+
while (index < frontmatterLines.length) {
|
|
296
|
+
const continuation = frontmatterLines[index];
|
|
297
|
+
if (continuation.startsWith(" ")) {
|
|
298
|
+
blockLines.push(continuation.slice(2));
|
|
299
|
+
index += 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (continuation.startsWith("\t")) {
|
|
303
|
+
blockLines.push(continuation.slice(1));
|
|
304
|
+
index += 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
frontmatter[key] = blockLines.join("\n").trim();
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!rawValue) {
|
|
314
|
+
const listItems = [];
|
|
315
|
+
let nextIndex = index + 1;
|
|
316
|
+
while (nextIndex < frontmatterLines.length) {
|
|
317
|
+
const continuation = frontmatterLines[nextIndex];
|
|
318
|
+
const listMatch = FRONTMATTER_LIST_ITEM_PATTERN.exec(continuation);
|
|
319
|
+
if (listMatch?.groups) {
|
|
320
|
+
listItems.push(parseScalar(listMatch.groups.value));
|
|
321
|
+
nextIndex += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
if (listItems.length > 0) {
|
|
327
|
+
frontmatter[key] = listItems;
|
|
328
|
+
index = nextIndex;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
frontmatter[key] = null;
|
|
332
|
+
index += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
frontmatter[key] = parseScalar(rawValue);
|
|
337
|
+
index += 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return frontmatter;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readMarkdownDocument(filePath) {
|
|
344
|
+
let content;
|
|
345
|
+
try {
|
|
346
|
+
content = readFileSync(filePath, "utf8");
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof TypeError) {
|
|
349
|
+
throw new RepoContractError(toPosixPath(filePath), "File must be valid UTF-8", 1);
|
|
350
|
+
}
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
355
|
+
if (normalized.startsWith("---\n")) {
|
|
356
|
+
const lines = normalized.split("\n");
|
|
357
|
+
let endIndex = null;
|
|
358
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
359
|
+
if (lines[index].trim() === "---") {
|
|
360
|
+
endIndex = index;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (endIndex == null) {
|
|
366
|
+
throw new RepoContractError(toPosixPath(filePath), "Frontmatter is missing a closing `---`", 1);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const frontmatterLines = lines.slice(1, endIndex);
|
|
370
|
+
const frontmatter = parseFrontmatter(frontmatterLines, filePath);
|
|
371
|
+
return {
|
|
372
|
+
path: filePath,
|
|
373
|
+
frontmatter,
|
|
374
|
+
body: lines.slice(endIndex + 1).join("\n"),
|
|
375
|
+
bodyStartLine: endIndex + 2,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
path: filePath,
|
|
381
|
+
frontmatter: {},
|
|
382
|
+
body: normalized,
|
|
383
|
+
bodyStartLine: 1,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function readJsonDocument(filePath, label) {
|
|
388
|
+
let content;
|
|
389
|
+
try {
|
|
390
|
+
content = readFileSync(filePath, "utf8");
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error instanceof TypeError) {
|
|
393
|
+
throw new RepoContractError(toPosixPath(filePath), "File must be valid UTF-8", 1);
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
return JSON.parse(content);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const positionMatch =
|
|
402
|
+
error instanceof SyntaxError ? /position\s+(?<position>\d+)/i.exec(error.message) : null;
|
|
403
|
+
const position = positionMatch?.groups?.position ? Number(positionMatch.groups.position) : null;
|
|
404
|
+
const line =
|
|
405
|
+
Number.isInteger(position) && position != null
|
|
406
|
+
? content.slice(0, position).split(/\r?\n/).length
|
|
407
|
+
: 1;
|
|
408
|
+
throw new RepoContractError(
|
|
409
|
+
toPosixPath(filePath),
|
|
410
|
+
`Invalid ${label} JSON`,
|
|
411
|
+
line
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function orderedDirectories(dirPath) {
|
|
417
|
+
const items = readdirSync(dirPath, { withFileTypes: true })
|
|
418
|
+
.filter((entry) => entry.isDirectory())
|
|
419
|
+
.map((entry) => path.join(dirPath, entry.name))
|
|
420
|
+
.sort((left, right) => orderedPathParts(left).order - orderedPathParts(right).order);
|
|
421
|
+
|
|
422
|
+
items.forEach((itemPath, index) => {
|
|
423
|
+
const { order } = orderedPathParts(itemPath);
|
|
424
|
+
if (order !== index + 1) {
|
|
425
|
+
throw new RepoContractError(
|
|
426
|
+
toPosixPath(itemPath),
|
|
427
|
+
`Ordering must be contiguous; expected \`${String(index + 1).padStart(2, "0")}-...\` here`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return items;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function orderedMarkdownFiles(dirPath) {
|
|
436
|
+
const items = readdirSync(dirPath, { withFileTypes: true })
|
|
437
|
+
.filter((entry) => entry.isFile() && path.extname(entry.name) === ".md")
|
|
438
|
+
.map((entry) => path.join(dirPath, entry.name))
|
|
439
|
+
.sort((left, right) => orderedPathParts(left).order - orderedPathParts(right).order);
|
|
440
|
+
|
|
441
|
+
items.forEach((itemPath, index) => {
|
|
442
|
+
const { order } = orderedPathParts(itemPath);
|
|
443
|
+
if (order !== index + 1) {
|
|
444
|
+
throw new RepoContractError(
|
|
445
|
+
toPosixPath(itemPath),
|
|
446
|
+
`Ordering must be contiguous; expected \`${String(index + 1).padStart(2, "0")}-...\` here`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return items;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function validateRequiredDirectory(dirPath, label) {
|
|
455
|
+
if (!statExists(dirPath, "directory")) {
|
|
456
|
+
throw new RepoContractError(toPosixPath(dirPath), `Missing required ${label} directory`);
|
|
457
|
+
}
|
|
458
|
+
return dirPath;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function validateRequiredFile(filePath, label) {
|
|
462
|
+
if (!statExists(filePath, "file")) {
|
|
463
|
+
throw new RepoContractError(toPosixPath(filePath), `Missing required ${label} file`);
|
|
464
|
+
}
|
|
465
|
+
return filePath;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function statExists(targetPath, expectedType) {
|
|
469
|
+
try {
|
|
470
|
+
const stats = statSync(targetPath);
|
|
471
|
+
return expectedType === "directory" ? stats.isDirectory() : stats.isFile();
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function extractNamedBlock(document, startMarker, endMarker) {
|
|
478
|
+
const lines = document.body.split("\n");
|
|
479
|
+
const contentLines = [];
|
|
480
|
+
const blockLines = [];
|
|
481
|
+
let inBlock = false;
|
|
482
|
+
let seenBlock = false;
|
|
483
|
+
|
|
484
|
+
lines.forEach((line, index) => {
|
|
485
|
+
const stripped = line.trim();
|
|
486
|
+
if (stripped === startMarker) {
|
|
487
|
+
if (inBlock || seenBlock) {
|
|
488
|
+
throw new RepoContractError(
|
|
489
|
+
toPosixPath(document.path),
|
|
490
|
+
`Only one \`${startMarker}\` block is allowed`,
|
|
491
|
+
document.bodyStartLine + index
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
inBlock = true;
|
|
495
|
+
seenBlock = true;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (stripped === endMarker) {
|
|
500
|
+
if (!inBlock) {
|
|
501
|
+
throw new RepoContractError(
|
|
502
|
+
toPosixPath(document.path),
|
|
503
|
+
`Found \`${endMarker}\` without a matching \`${startMarker}\``,
|
|
504
|
+
document.bodyStartLine + index
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
inBlock = false;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (inBlock) {
|
|
512
|
+
blockLines.push(line);
|
|
513
|
+
} else {
|
|
514
|
+
contentLines.push(line);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
if (inBlock) {
|
|
519
|
+
throw new RepoContractError(
|
|
520
|
+
toPosixPath(document.path),
|
|
521
|
+
`Missing closing \`${endMarker}\` for \`${startMarker}\` block`,
|
|
522
|
+
document.bodyStartLine + lines.length
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
content: contentLines.join("\n").trim(),
|
|
528
|
+
block: blockLines.join("\n").trim(),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function parseChoiceMetadata(document, metadataLines, choiceStartLine) {
|
|
533
|
+
const metadata = {};
|
|
534
|
+
const allowedKeys = new Set(["title", "correct", "feedback"]);
|
|
535
|
+
let index = 0;
|
|
536
|
+
|
|
537
|
+
while (index < metadataLines.length) {
|
|
538
|
+
const rawLine = metadataLines[index];
|
|
539
|
+
const lineNumber = choiceStartLine + 1 + index;
|
|
540
|
+
|
|
541
|
+
if (/^[ \t]/.test(rawLine)) {
|
|
542
|
+
throw new RepoContractError(
|
|
543
|
+
toPosixPath(document.path),
|
|
544
|
+
"Choice metadata continuation lines must follow a `key: |` entry",
|
|
545
|
+
lineNumber
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const match = CHOICE_METADATA_PATTERN.exec(rawLine);
|
|
550
|
+
if (!match?.groups) {
|
|
551
|
+
throw new RepoContractError(
|
|
552
|
+
toPosixPath(document.path),
|
|
553
|
+
"Choice metadata lines must use `key: value` syntax",
|
|
554
|
+
lineNumber
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const key = match.groups.key;
|
|
559
|
+
if (!allowedKeys.has(key)) {
|
|
560
|
+
throw new RepoContractError(
|
|
561
|
+
toPosixPath(document.path),
|
|
562
|
+
`Unknown choice metadata field \`${key}\``,
|
|
563
|
+
lineNumber
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (Object.prototype.hasOwnProperty.call(metadata, key)) {
|
|
567
|
+
throw new RepoContractError(
|
|
568
|
+
toPosixPath(document.path),
|
|
569
|
+
`Duplicate choice metadata field \`${key}\``,
|
|
570
|
+
lineNumber
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const value = (match.groups.value ?? "").trimEnd();
|
|
575
|
+
if (value === "|") {
|
|
576
|
+
const blockLines = [];
|
|
577
|
+
index += 1;
|
|
578
|
+
while (index < metadataLines.length) {
|
|
579
|
+
const continuation = metadataLines[index];
|
|
580
|
+
if (continuation.startsWith(" ")) {
|
|
581
|
+
blockLines.push(continuation.slice(2));
|
|
582
|
+
index += 1;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (continuation.startsWith("\t")) {
|
|
586
|
+
blockLines.push(continuation.slice(1));
|
|
587
|
+
index += 1;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
metadata[key] = blockLines.join("\n").trim();
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
metadata[key] = value;
|
|
597
|
+
index += 1;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return metadata;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function parseChoiceBlocks(document) {
|
|
604
|
+
const lines = document.body.split("\n");
|
|
605
|
+
const promptLines = [];
|
|
606
|
+
const choices = [];
|
|
607
|
+
let index = 0;
|
|
608
|
+
let foundChoice = false;
|
|
609
|
+
|
|
610
|
+
while (index < lines.length) {
|
|
611
|
+
const stripped = lines[index].trim();
|
|
612
|
+
if (stripped !== CHOICE_START) {
|
|
613
|
+
if (foundChoice) {
|
|
614
|
+
if (!stripped) {
|
|
615
|
+
index += 1;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
throw new RepoContractError(
|
|
619
|
+
toPosixPath(document.path),
|
|
620
|
+
"All content after the first `@choice` must stay inside choice blocks",
|
|
621
|
+
document.bodyStartLine + index
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
promptLines.push(lines[index]);
|
|
625
|
+
index += 1;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
foundChoice = true;
|
|
630
|
+
const choiceStartLine = document.bodyStartLine + index;
|
|
631
|
+
index += 1;
|
|
632
|
+
|
|
633
|
+
const metadataLines = [];
|
|
634
|
+
while (index < lines.length && lines[index].trim()) {
|
|
635
|
+
if (lines[index].trim() === CHOICE_END) {
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
metadataLines.push(lines[index]);
|
|
639
|
+
index += 1;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (index < lines.length && lines[index].trim() === CHOICE_END) {
|
|
643
|
+
throw new RepoContractError(
|
|
644
|
+
toPosixPath(document.path),
|
|
645
|
+
"Choice metadata must be followed by a blank line before the choice body",
|
|
646
|
+
document.bodyStartLine + index
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (metadataLines.length === 0) {
|
|
651
|
+
throw new RepoContractError(
|
|
652
|
+
toPosixPath(document.path),
|
|
653
|
+
"Each `@choice` block must include metadata lines before the blank line",
|
|
654
|
+
choiceStartLine
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const metadata = parseChoiceMetadata(document, metadataLines, choiceStartLine);
|
|
659
|
+
|
|
660
|
+
if (index < lines.length && !lines[index].trim()) {
|
|
661
|
+
index += 1;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const bodyLines = [];
|
|
665
|
+
while (index < lines.length) {
|
|
666
|
+
const marker = lines[index].trim();
|
|
667
|
+
if (marker === CHOICE_START) {
|
|
668
|
+
throw new RepoContractError(
|
|
669
|
+
toPosixPath(document.path),
|
|
670
|
+
"Nested `@choice` blocks are not allowed",
|
|
671
|
+
document.bodyStartLine + index
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (marker === CHOICE_END) {
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
bodyLines.push(lines[index]);
|
|
678
|
+
index += 1;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (index >= lines.length || lines[index].trim() !== CHOICE_END) {
|
|
682
|
+
throw new RepoContractError(
|
|
683
|
+
toPosixPath(document.path),
|
|
684
|
+
"Missing `@endchoice` for choice block",
|
|
685
|
+
choiceStartLine
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const bodyMarkdown = bodyLines.join("\n").trim();
|
|
690
|
+
const title = ensureString(metadata.title, document.path, choiceStartLine, "choice.title");
|
|
691
|
+
if (!bodyMarkdown && !title) {
|
|
692
|
+
throw new RepoContractError(
|
|
693
|
+
toPosixPath(document.path),
|
|
694
|
+
"Choice body cannot be empty unless `title` is provided",
|
|
695
|
+
choiceStartLine
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!Object.prototype.hasOwnProperty.call(metadata, "correct")) {
|
|
700
|
+
throw new RepoContractError(
|
|
701
|
+
toPosixPath(document.path),
|
|
702
|
+
"choice.correct is required",
|
|
703
|
+
choiceStartLine
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
choices.push({
|
|
708
|
+
title,
|
|
709
|
+
correct: parseExplicitBoolean(metadata.correct, document.path, choiceStartLine, "choice.correct"),
|
|
710
|
+
feedback:
|
|
711
|
+
typeof metadata.feedback === "string" && metadata.feedback.trim()
|
|
712
|
+
? metadata.feedback.trim()
|
|
713
|
+
: null,
|
|
714
|
+
bodyMarkdown: bodyMarkdown || title || "",
|
|
715
|
+
line: choiceStartLine,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
index += 1;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
promptMarkdown: promptLines.join("\n").trim(),
|
|
723
|
+
choices,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const SUPPORTED_EXERCISE_LANGUAGES = new Set(["python", "r"]);
|
|
728
|
+
const LANGUAGE_EXTENSIONS = {
|
|
729
|
+
python: new Set(["py"]),
|
|
730
|
+
r: new Set(["r"]),
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
function parseSlideFile(rootPath, filePath) {
|
|
734
|
+
const document = readMarkdownDocument(filePath);
|
|
735
|
+
const { slug } = orderedPathParts(filePath);
|
|
736
|
+
const title = ensureString(document.frontmatter.title, filePath, 1, "title") || slugToTitle(slug);
|
|
737
|
+
const { content, block } = extractNamedBlock(document, SCRIPT_START, SCRIPT_END);
|
|
738
|
+
const { html } = renderSlideMarkdown(content, {
|
|
739
|
+
onError(message, line) {
|
|
740
|
+
throw new RepoContractError(toPosixPath(filePath), message, document.bodyStartLine + line - 1);
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
return {
|
|
744
|
+
repoKey: relativePosix(rootPath, filePath).replace(/\.md$/, ""),
|
|
745
|
+
repoPath: relativePosix(rootPath, filePath),
|
|
746
|
+
title,
|
|
747
|
+
contentMarkdown: content,
|
|
748
|
+
contentHtml: html,
|
|
749
|
+
script: block,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function parseVideoFile(rootPath, filePath, titleFallback) {
|
|
754
|
+
const document = readMarkdownDocument(filePath);
|
|
755
|
+
const title = ensureString(document.frontmatter.title, filePath, 1, "title") || titleFallback;
|
|
756
|
+
const videoUrl = ensureVideoUrl(document.frontmatter.video_url, filePath, 1);
|
|
757
|
+
return {
|
|
758
|
+
repoKey: relativePosix(rootPath, filePath).replace(/\.md$/, ""),
|
|
759
|
+
repoPath: relativePosix(rootPath, filePath),
|
|
760
|
+
title,
|
|
761
|
+
videoUrl,
|
|
762
|
+
notesMarkdown: document.body.trim(),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function parseQuizQuestion(rootPath, filePath, warnings) {
|
|
767
|
+
const document = readMarkdownDocument(filePath);
|
|
768
|
+
const { slug } = orderedPathParts(filePath);
|
|
769
|
+
const title = ensureString(document.frontmatter.title, filePath, 1, "title") || slugToTitle(slug);
|
|
770
|
+
const { promptMarkdown, choices } = parseChoiceBlocks(document);
|
|
771
|
+
|
|
772
|
+
if (choices.length === 0) {
|
|
773
|
+
throw new RepoContractError(
|
|
774
|
+
toPosixPath(filePath),
|
|
775
|
+
"Quiz question must include at least one `@choice` block"
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const correctCount = choices.filter((choice) => choice.correct).length;
|
|
780
|
+
if (correctCount === 0) {
|
|
781
|
+
throw new RepoContractError(
|
|
782
|
+
toPosixPath(filePath),
|
|
783
|
+
"Quiz question must include at least one correct choice"
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const correctFeedback = document.frontmatter.correct_feedback;
|
|
788
|
+
if (correctFeedback != null && typeof correctFeedback !== "string") {
|
|
789
|
+
throw new RepoContractError(toPosixPath(filePath), "correct_feedback must be a string", 1);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const repoPath = relativePosix(rootPath, filePath);
|
|
793
|
+
for (const choice of choices) {
|
|
794
|
+
if (!choice.title || !choice.bodyMarkdown) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const normalizedTitle = normalizeInlineText(choice.title);
|
|
799
|
+
const normalizedBody = plainTextFromMarkdown(choice.bodyMarkdown);
|
|
800
|
+
if (normalizedTitle && normalizedBody && normalizedTitle !== normalizedBody) {
|
|
801
|
+
warnings.push(
|
|
802
|
+
`Choice title differs from body in \`${repoPath}:${choice.line}\`; platform import uses the choice body as the visible answer text, so put the learner-facing answer in the choice body and use feedback for explanations.`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
repoKey: repoPath.replace(/\.md$/, ""),
|
|
809
|
+
repoPath,
|
|
810
|
+
title,
|
|
811
|
+
questionMarkdown: promptMarkdown || title,
|
|
812
|
+
correctFeedback:
|
|
813
|
+
typeof correctFeedback === "string" && correctFeedback.trim()
|
|
814
|
+
? correctFeedback.trim()
|
|
815
|
+
: null,
|
|
816
|
+
randomizeChoices: ensureBoolean(
|
|
817
|
+
document.frontmatter.randomize_choices,
|
|
818
|
+
filePath,
|
|
819
|
+
1,
|
|
820
|
+
"randomize_choices"
|
|
821
|
+
),
|
|
822
|
+
isMultipleChoice: correctCount > 1,
|
|
823
|
+
choices,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function parseExerciseSections(document) {
|
|
828
|
+
const lines = document.body.split(/\r?\n/);
|
|
829
|
+
const sectionOrder = [];
|
|
830
|
+
const sectionContent = new Map();
|
|
831
|
+
let currentSection = null;
|
|
832
|
+
let currentLines = [];
|
|
833
|
+
|
|
834
|
+
const finalizeCurrent = () => {
|
|
835
|
+
if (currentSection == null) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const markdown = currentLines.join("\n").trim();
|
|
839
|
+
sectionContent.set(currentSection, markdown || null);
|
|
840
|
+
currentSection = null;
|
|
841
|
+
currentLines = [];
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
845
|
+
const rawLine = lines[index];
|
|
846
|
+
const stripped = rawLine.trim();
|
|
847
|
+
const headingMatch = MARKDOWN_HEADING_PATTERN.exec(rawLine);
|
|
848
|
+
const headingName = headingMatch?.[2]?.trim().toLowerCase();
|
|
849
|
+
|
|
850
|
+
if (headingName === "context" || headingName === "instructions") {
|
|
851
|
+
if (sectionContent.has(headingName) || currentSection === headingName) {
|
|
852
|
+
throw new RepoContractError(
|
|
853
|
+
toPosixPath(document.path),
|
|
854
|
+
`Exercise body cannot repeat the \`## ${headingName.charAt(0).toUpperCase()}${headingName.slice(1)}\` section`,
|
|
855
|
+
document.bodyStartLine + index
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
finalizeCurrent();
|
|
859
|
+
currentSection = headingName;
|
|
860
|
+
sectionOrder.push(headingName);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (currentSection == null) {
|
|
865
|
+
if (stripped) {
|
|
866
|
+
throw new RepoContractError(
|
|
867
|
+
toPosixPath(document.path),
|
|
868
|
+
"Exercise body must use `## Context` followed by `## Instructions` sections",
|
|
869
|
+
document.bodyStartLine + index
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
currentLines.push(rawLine);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
finalizeCurrent();
|
|
879
|
+
|
|
880
|
+
if (sectionOrder.length !== 2 || sectionOrder[0] !== "context" || sectionOrder[1] !== "instructions") {
|
|
881
|
+
throw new RepoContractError(
|
|
882
|
+
toPosixPath(document.path),
|
|
883
|
+
"Exercise body must use `## Context` followed by `## Instructions` sections",
|
|
884
|
+
document.bodyStartLine
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
contextMarkdown: sectionContent.get("context") ?? null,
|
|
890
|
+
instructionsMarkdown: sectionContent.get("instructions") ?? null,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function parseExerciseDirectory(rootPath, dirPath, titleFallback) {
|
|
895
|
+
const exercisePath = validateRequiredFile(path.join(dirPath, "exercise.md"), "exercise");
|
|
896
|
+
const document = readMarkdownDocument(exercisePath);
|
|
897
|
+
const title = ensureString(document.frontmatter.title, exercisePath, 1, "title") || titleFallback;
|
|
898
|
+
const language = ensureString(document.frontmatter.language, exercisePath, 1, "language", {
|
|
899
|
+
required: true,
|
|
900
|
+
});
|
|
901
|
+
const normalizedLanguage = language.toLowerCase();
|
|
902
|
+
if (!SUPPORTED_EXERCISE_LANGUAGES.has(normalizedLanguage)) {
|
|
903
|
+
throw new RepoContractError(
|
|
904
|
+
toPosixPath(exercisePath),
|
|
905
|
+
"language must be one of `python` or `r`",
|
|
906
|
+
1
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
const { contextMarkdown, instructionsMarkdown } = parseExerciseSections(document);
|
|
910
|
+
|
|
911
|
+
const starterCandidates = readdirSync(dirPath, { withFileTypes: true })
|
|
912
|
+
.filter((entry) => entry.isFile() && path.parse(entry.name).name === "starter")
|
|
913
|
+
.map((entry) => path.join(dirPath, entry.name))
|
|
914
|
+
.sort();
|
|
915
|
+
const solutionCandidates = readdirSync(dirPath, { withFileTypes: true })
|
|
916
|
+
.filter((entry) => entry.isFile() && path.parse(entry.name).name === "solution")
|
|
917
|
+
.map((entry) => path.join(dirPath, entry.name))
|
|
918
|
+
.sort();
|
|
919
|
+
|
|
920
|
+
if (starterCandidates.length !== 1) {
|
|
921
|
+
throw new RepoContractError(
|
|
922
|
+
toPosixPath(dirPath),
|
|
923
|
+
"Exercise directory must contain exactly one `starter.*` file"
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
if (solutionCandidates.length !== 1) {
|
|
927
|
+
throw new RepoContractError(
|
|
928
|
+
toPosixPath(dirPath),
|
|
929
|
+
"Exercise directory must contain exactly one `solution.*` file"
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const starterPath = starterCandidates[0];
|
|
934
|
+
const solutionPath = solutionCandidates[0];
|
|
935
|
+
const starterExtension = path.extname(starterPath).slice(1).toLowerCase();
|
|
936
|
+
const solutionExtension = path.extname(solutionPath).slice(1).toLowerCase();
|
|
937
|
+
if (starterExtension !== solutionExtension) {
|
|
938
|
+
throw new RepoContractError(
|
|
939
|
+
toPosixPath(dirPath),
|
|
940
|
+
"starter and solution files must use the same extension"
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const supportedExtensions = LANGUAGE_EXTENSIONS[normalizedLanguage];
|
|
945
|
+
if (!supportedExtensions.has(starterExtension)) {
|
|
946
|
+
throw new RepoContractError(
|
|
947
|
+
toPosixPath(starterPath),
|
|
948
|
+
`starter file extension \`.${starterExtension}\` does not match language \`${normalizedLanguage}\``
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
repoKey: relativePosix(rootPath, exercisePath).replace(/\.md$/, ""),
|
|
954
|
+
repoPath: relativePosix(rootPath, dirPath),
|
|
955
|
+
title,
|
|
956
|
+
language: normalizedLanguage,
|
|
957
|
+
bodyMarkdown: document.body.trim(),
|
|
958
|
+
contextMarkdown,
|
|
959
|
+
instructionsMarkdown,
|
|
960
|
+
starterPath: relativePosix(rootPath, starterPath),
|
|
961
|
+
solutionPath: relativePosix(rootPath, solutionPath),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function parseSpreadsheetChecks(checksPath) {
|
|
966
|
+
const rawChecks = readJsonDocument(checksPath, "spreadsheet checks");
|
|
967
|
+
if (!Array.isArray(rawChecks)) {
|
|
968
|
+
throw new RepoContractError(
|
|
969
|
+
toPosixPath(checksPath),
|
|
970
|
+
"checks.json must contain a JSON array",
|
|
971
|
+
1
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return rawChecks.map((rawCheck, index) => {
|
|
976
|
+
if (!rawCheck || typeof rawCheck !== "object" || Array.isArray(rawCheck)) {
|
|
977
|
+
throw new RepoContractError(
|
|
978
|
+
toPosixPath(checksPath),
|
|
979
|
+
`checks[${index}] must be a JSON object`,
|
|
980
|
+
1
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const id = ensureString(rawCheck.id, checksPath, 1, `checks[${index}].id`, {
|
|
985
|
+
required: true,
|
|
986
|
+
});
|
|
987
|
+
const label = ensureString(rawCheck.label, checksPath, 1, `checks[${index}].label`, {
|
|
988
|
+
required: true,
|
|
989
|
+
});
|
|
990
|
+
const type = ensureString(rawCheck.type, checksPath, 1, `checks[${index}].type`, {
|
|
991
|
+
required: true,
|
|
992
|
+
});
|
|
993
|
+
if (!SPREADSHEET_CHECK_TYPES.has(type)) {
|
|
994
|
+
throw new RepoContractError(
|
|
995
|
+
toPosixPath(checksPath),
|
|
996
|
+
`checks[${index}].type must be one of ${Array.from(SPREADSHEET_CHECK_TYPES)
|
|
997
|
+
.map((value) => `\`${value}\``)
|
|
998
|
+
.join(", ")}`,
|
|
999
|
+
1
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const cell = ensureString(rawCheck.cell, checksPath, 1, `checks[${index}].cell`, {
|
|
1004
|
+
required: true,
|
|
1005
|
+
});
|
|
1006
|
+
const sheetName = ensureString(
|
|
1007
|
+
rawCheck.sheet_name,
|
|
1008
|
+
checksPath,
|
|
1009
|
+
1,
|
|
1010
|
+
`checks[${index}].sheet_name`
|
|
1011
|
+
);
|
|
1012
|
+
const formula = ensureString(rawCheck.formula, checksPath, 1, `checks[${index}].formula`);
|
|
1013
|
+
const details = ensureString(rawCheck.details, checksPath, 1, `checks[${index}].details`);
|
|
1014
|
+
|
|
1015
|
+
if (rawCheck.values != null && !Array.isArray(rawCheck.values)) {
|
|
1016
|
+
throw new RepoContractError(
|
|
1017
|
+
toPosixPath(checksPath),
|
|
1018
|
+
`checks[${index}].values must be an array`,
|
|
1019
|
+
1
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (
|
|
1024
|
+
rawCheck.tolerance != null &&
|
|
1025
|
+
(typeof rawCheck.tolerance !== "number" || !Number.isFinite(rawCheck.tolerance))
|
|
1026
|
+
) {
|
|
1027
|
+
throw new RepoContractError(
|
|
1028
|
+
toPosixPath(checksPath),
|
|
1029
|
+
`checks[${index}].tolerance must be a number`,
|
|
1030
|
+
1
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
id,
|
|
1036
|
+
label,
|
|
1037
|
+
type,
|
|
1038
|
+
sheetName,
|
|
1039
|
+
cell,
|
|
1040
|
+
value: rawCheck.value ?? null,
|
|
1041
|
+
values: rawCheck.values ?? [],
|
|
1042
|
+
formula,
|
|
1043
|
+
tolerance: rawCheck.tolerance ?? null,
|
|
1044
|
+
details,
|
|
1045
|
+
};
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function parseSpreadsheetDirectory(rootPath, dirPath, titleFallback) {
|
|
1050
|
+
const exercisePath = validateRequiredFile(path.join(dirPath, "exercise.md"), "spreadsheet exercise");
|
|
1051
|
+
const workbookPath = validateRequiredFile(path.join(dirPath, "workbook.json"), "spreadsheet workbook");
|
|
1052
|
+
const solutionPath = validateRequiredFile(path.join(dirPath, "solution.json"), "spreadsheet solution");
|
|
1053
|
+
const checksPath = validateRequiredFile(path.join(dirPath, "checks.json"), "spreadsheet checks");
|
|
1054
|
+
|
|
1055
|
+
const document = readMarkdownDocument(exercisePath);
|
|
1056
|
+
const title = ensureString(document.frontmatter.title, exercisePath, 1, "title") || titleFallback;
|
|
1057
|
+
const engine =
|
|
1058
|
+
ensureString(document.frontmatter.engine, exercisePath, 1, "engine") || "univer";
|
|
1059
|
+
const normalizedEngine = engine.toLowerCase();
|
|
1060
|
+
if (!SUPPORTED_SPREADSHEET_ENGINES.has(normalizedEngine)) {
|
|
1061
|
+
throw new RepoContractError(
|
|
1062
|
+
toPosixPath(exercisePath),
|
|
1063
|
+
"engine must be `univer`",
|
|
1064
|
+
1
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const { contextMarkdown, instructionsMarkdown } = parseExerciseSections(document);
|
|
1069
|
+
const starterWorkbook = readJsonDocument(workbookPath, "spreadsheet workbook");
|
|
1070
|
+
if (!starterWorkbook || typeof starterWorkbook !== "object" || Array.isArray(starterWorkbook)) {
|
|
1071
|
+
throw new RepoContractError(
|
|
1072
|
+
toPosixPath(workbookPath),
|
|
1073
|
+
"workbook.json must contain a JSON object",
|
|
1074
|
+
1
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const solutionWorkbook = readJsonDocument(solutionPath, "spreadsheet solution");
|
|
1079
|
+
if (!solutionWorkbook || typeof solutionWorkbook !== "object" || Array.isArray(solutionWorkbook)) {
|
|
1080
|
+
throw new RepoContractError(
|
|
1081
|
+
toPosixPath(solutionPath),
|
|
1082
|
+
"solution.json must contain a JSON object",
|
|
1083
|
+
1
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const checks = parseSpreadsheetChecks(checksPath);
|
|
1088
|
+
const bodyMarkdown = document.body.trim();
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
repoKey: relativePosix(rootPath, exercisePath).replace(/\.md$/, ""),
|
|
1092
|
+
repoPath: relativePosix(rootPath, dirPath),
|
|
1093
|
+
title,
|
|
1094
|
+
engine: normalizedEngine,
|
|
1095
|
+
bodyMarkdown,
|
|
1096
|
+
contextMarkdown,
|
|
1097
|
+
instructionsMarkdown,
|
|
1098
|
+
starterWorkbook,
|
|
1099
|
+
solutionWorkbook,
|
|
1100
|
+
checks,
|
|
1101
|
+
workbookPath: relativePosix(rootPath, workbookPath),
|
|
1102
|
+
solutionPath: relativePosix(rootPath, solutionPath),
|
|
1103
|
+
checksPath: relativePosix(rootPath, checksPath),
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function parseLessonDirectory(rootPath, dirPath, warnings) {
|
|
1108
|
+
const { slug } = orderedPathParts(dirPath);
|
|
1109
|
+
const lessonPath = validateRequiredFile(path.join(dirPath, "lesson.md"), "lesson");
|
|
1110
|
+
const document = readMarkdownDocument(lessonPath);
|
|
1111
|
+
|
|
1112
|
+
const title = ensureString(document.frontmatter.title, lessonPath, 1, "title") || slugToTitle(slug);
|
|
1113
|
+
const kind = ensureString(document.frontmatter.kind, lessonPath, 1, "kind", { required: true });
|
|
1114
|
+
if (!["slides", "video", "quiz", "coding_exercise", "spreadsheet_lab"].includes(kind)) {
|
|
1115
|
+
throw new RepoContractError(
|
|
1116
|
+
toPosixPath(lessonPath),
|
|
1117
|
+
"kind must be one of `slides`, `video`, `quiz`, `coding_exercise`, or `spreadsheet_lab`",
|
|
1118
|
+
1
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
let resource;
|
|
1123
|
+
if (kind === "slides") {
|
|
1124
|
+
const slidesDir = validateRequiredDirectory(path.join(dirPath, "slides"), "slides");
|
|
1125
|
+
const slideFiles = orderedMarkdownFiles(slidesDir);
|
|
1126
|
+
if (slideFiles.length === 0) {
|
|
1127
|
+
throw new RepoContractError(
|
|
1128
|
+
toPosixPath(slidesDir),
|
|
1129
|
+
"Slides lessons must contain at least one slide file"
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
resource = {
|
|
1133
|
+
type: "slides",
|
|
1134
|
+
title,
|
|
1135
|
+
slides: slideFiles.map((slidePath) => parseSlideFile(rootPath, slidePath)),
|
|
1136
|
+
};
|
|
1137
|
+
} else if (kind === "video") {
|
|
1138
|
+
const videoPath = validateRequiredFile(path.join(dirPath, "video.md"), "video");
|
|
1139
|
+
resource = {
|
|
1140
|
+
type: "video",
|
|
1141
|
+
...parseVideoFile(rootPath, videoPath, title),
|
|
1142
|
+
};
|
|
1143
|
+
} else if (kind === "quiz") {
|
|
1144
|
+
const quizDir = validateRequiredDirectory(path.join(dirPath, "quiz"), "quiz");
|
|
1145
|
+
const quizPath = validateRequiredFile(path.join(quizDir, "quiz.md"), "quiz metadata");
|
|
1146
|
+
const quizDocument = readMarkdownDocument(quizPath);
|
|
1147
|
+
const quizTitle = ensureString(quizDocument.frontmatter.title, quizPath, 1, "title") || title;
|
|
1148
|
+
const questionsDir = validateRequiredDirectory(path.join(quizDir, "questions"), "quiz questions");
|
|
1149
|
+
const questionFiles = orderedMarkdownFiles(questionsDir);
|
|
1150
|
+
if (questionFiles.length === 0) {
|
|
1151
|
+
throw new RepoContractError(
|
|
1152
|
+
toPosixPath(questionsDir),
|
|
1153
|
+
"Quiz lessons must contain at least one question file"
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
resource = {
|
|
1157
|
+
type: "quiz",
|
|
1158
|
+
title: quizTitle,
|
|
1159
|
+
description: quizDocument.body.trim() || null,
|
|
1160
|
+
questions: questionFiles.map((questionPath) => parseQuizQuestion(rootPath, questionPath, warnings)),
|
|
1161
|
+
};
|
|
1162
|
+
} else if (kind === "coding_exercise") {
|
|
1163
|
+
const exerciseDir = validateRequiredDirectory(path.join(dirPath, "exercise"), "exercise");
|
|
1164
|
+
resource = {
|
|
1165
|
+
type: "coding_exercise",
|
|
1166
|
+
...parseExerciseDirectory(rootPath, exerciseDir, title),
|
|
1167
|
+
};
|
|
1168
|
+
} else {
|
|
1169
|
+
const spreadsheetDir = validateRequiredDirectory(path.join(dirPath, "spreadsheet"), "spreadsheet");
|
|
1170
|
+
resource = {
|
|
1171
|
+
type: "spreadsheet_lab",
|
|
1172
|
+
...parseSpreadsheetDirectory(rootPath, spreadsheetDir, title),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return {
|
|
1177
|
+
repoKey: relativePosix(rootPath, dirPath),
|
|
1178
|
+
repoPath: relativePosix(rootPath, dirPath),
|
|
1179
|
+
title,
|
|
1180
|
+
summaryMarkdown: document.body.trim(),
|
|
1181
|
+
kind,
|
|
1182
|
+
resource,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export function parseCourseRepository(rootPath) {
|
|
1187
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
1188
|
+
const coursePath = validateRequiredFile(path.join(resolvedRoot, "course.md"), "course");
|
|
1189
|
+
const courseDocument = readMarkdownDocument(coursePath);
|
|
1190
|
+
|
|
1191
|
+
const schemaVersion = ensureInteger(courseDocument.frontmatter.schema_version, coursePath, 1, "schema_version");
|
|
1192
|
+
if (schemaVersion !== 1) {
|
|
1193
|
+
throw new RepoContractError(toPosixPath(coursePath), "schema_version must be 1", 1);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const courseTitle = ensureString(courseDocument.frontmatter.title, coursePath, 1, "title", {
|
|
1197
|
+
required: true,
|
|
1198
|
+
});
|
|
1199
|
+
const duration = ensureInteger(courseDocument.frontmatter.duration, coursePath, 1, "duration");
|
|
1200
|
+
const thumbnail = ensureString(courseDocument.frontmatter.thumbnail, coursePath, 1, "thumbnail");
|
|
1201
|
+
const rawTags = courseDocument.frontmatter.tags;
|
|
1202
|
+
let tags = [];
|
|
1203
|
+
if (rawTags == null) {
|
|
1204
|
+
tags = [];
|
|
1205
|
+
} else if (Array.isArray(rawTags) && rawTags.every((item) => typeof item === "string")) {
|
|
1206
|
+
tags = rawTags.map((item) => item.trim()).filter(Boolean);
|
|
1207
|
+
} else {
|
|
1208
|
+
throw new RepoContractError(toPosixPath(coursePath), "tags must be a list of strings", 1);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const chaptersDir = validateRequiredDirectory(path.join(resolvedRoot, "chapters"), "chapters");
|
|
1212
|
+
const chapterDirs = orderedDirectories(chaptersDir);
|
|
1213
|
+
|
|
1214
|
+
const warnings = [];
|
|
1215
|
+
for (const requiredDoc of [".agents/rules/general.md"]) {
|
|
1216
|
+
if (!statExists(path.join(resolvedRoot, requiredDoc), "file")) {
|
|
1217
|
+
warnings.push(`Missing recommended rules file \`${requiredDoc}\``);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const chapters = chapterDirs.map((chapterDir) => {
|
|
1222
|
+
const { slug } = orderedPathParts(chapterDir);
|
|
1223
|
+
const chapterPath = validateRequiredFile(path.join(chapterDir, "chapter.md"), "chapter");
|
|
1224
|
+
const chapterDocument = readMarkdownDocument(chapterPath);
|
|
1225
|
+
const title = ensureString(chapterDocument.frontmatter.title, chapterPath, 1, "title") || slugToTitle(slug);
|
|
1226
|
+
|
|
1227
|
+
const lessonsDir = validateRequiredDirectory(path.join(chapterDir, "lessons"), "lessons");
|
|
1228
|
+
const lessonDirs = orderedDirectories(lessonsDir);
|
|
1229
|
+
if (lessonDirs.length === 0) {
|
|
1230
|
+
throw new RepoContractError(
|
|
1231
|
+
toPosixPath(lessonsDir),
|
|
1232
|
+
"Chapter must contain at least one lesson"
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
repoKey: relativePosix(resolvedRoot, chapterDir),
|
|
1238
|
+
repoPath: relativePosix(resolvedRoot, chapterDir),
|
|
1239
|
+
title,
|
|
1240
|
+
description: chapterDocument.body.trim() || null,
|
|
1241
|
+
lessons: lessonDirs.map((lessonDir) => parseLessonDirectory(resolvedRoot, lessonDir, warnings)),
|
|
1242
|
+
};
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
schemaVersion: 1,
|
|
1247
|
+
contract: "repo_tree_v1",
|
|
1248
|
+
warnings,
|
|
1249
|
+
course: {
|
|
1250
|
+
repoKey: "course",
|
|
1251
|
+
repoPath: "course.md",
|
|
1252
|
+
title: courseTitle,
|
|
1253
|
+
description: courseDocument.body.trim(),
|
|
1254
|
+
duration,
|
|
1255
|
+
thumbnail,
|
|
1256
|
+
tags,
|
|
1257
|
+
},
|
|
1258
|
+
chapters,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export function buildValidationSummary(rootPath, parsedRepo) {
|
|
1263
|
+
const resourceCounts = {
|
|
1264
|
+
slides: 0,
|
|
1265
|
+
video: 0,
|
|
1266
|
+
quiz: 0,
|
|
1267
|
+
coding_exercise: 0,
|
|
1268
|
+
spreadsheet_lab: 0,
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
let lessonCount = 0;
|
|
1272
|
+
for (const chapter of parsedRepo.chapters) {
|
|
1273
|
+
lessonCount += chapter.lessons.length;
|
|
1274
|
+
for (const lesson of chapter.lessons) {
|
|
1275
|
+
if (lesson.resource?.type && Object.hasOwn(resourceCounts, lesson.resource.type)) {
|
|
1276
|
+
resourceCounts[lesson.resource.type] += 1;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
return {
|
|
1282
|
+
ok: true,
|
|
1283
|
+
path: toPosixPath(path.resolve(rootPath)),
|
|
1284
|
+
contract: parsedRepo.contract,
|
|
1285
|
+
courseTitle: parsedRepo.course.title,
|
|
1286
|
+
chapterCount: parsedRepo.chapters.length,
|
|
1287
|
+
lessonCount,
|
|
1288
|
+
resourceCounts,
|
|
1289
|
+
warnings: parsedRepo.warnings,
|
|
1290
|
+
};
|
|
1291
|
+
}
|