@lxpack/validators 0.1.0 → 0.2.0
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 +149 -0
- package/dist/index.d.ts +232 -21
- package/dist/index.js +445 -62
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @lxpack/validators
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@lxpack/validators)
|
|
4
|
+
[](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
|
|
8
|
+
Zod schemas and filesystem validation for LXPack course manifests — including flow, variables, and component lessons (v0.2.0).
|
|
9
|
+
|
|
10
|
+
Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
|
|
11
|
+
|
|
12
|
+
| Related | Package |
|
|
13
|
+
|---------|---------|
|
|
14
|
+
| CLI | [`@lxpack/cli`](../cli/README.md) |
|
|
15
|
+
| Packaging | [`@lxpack/scorm`](../scorm/README.md) |
|
|
16
|
+
| Runtime | [`@lxpack/runtime`](../runtime/README.md) |
|
|
17
|
+
| Components | [`@lxpack/components`](../components/README.md) |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @lxpack/validators
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Node.js 20+.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {
|
|
31
|
+
validateCourse,
|
|
32
|
+
loadManifest,
|
|
33
|
+
buildRuntimeAssessmentBundle,
|
|
34
|
+
courseManifestSchema,
|
|
35
|
+
type ValidationResult,
|
|
36
|
+
} from "@lxpack/validators";
|
|
37
|
+
|
|
38
|
+
const result: ValidationResult = await validateCourse("/path/to/my-course");
|
|
39
|
+
|
|
40
|
+
if (!result.valid) {
|
|
41
|
+
for (const issue of result.issues) {
|
|
42
|
+
console.error(`${issue.path}: ${issue.message}`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
const { manifest } = result;
|
|
46
|
+
const bundle = await buildRuntimeAssessmentBundle("/path/to/my-course", manifest);
|
|
47
|
+
// bundle.assessments — learner-facing questions (no correct flags)
|
|
48
|
+
// bundle.answerKeys — scoring keys for the runtime
|
|
49
|
+
// bundle.configs — maxAttempts, shuffleChoices, showFeedback per assessment
|
|
50
|
+
// bundle.feedback — questionId → explanation text for feedback modes
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Load and parse only
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const loaded = await loadManifest("/path/to/my-course");
|
|
58
|
+
if (Array.isArray(loaded)) {
|
|
59
|
+
// validation issues
|
|
60
|
+
} else {
|
|
61
|
+
const { manifest } = loaded;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Schema-only validation
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const parsed = courseManifestSchema.safeParse(manifestObject);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Flow validation
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { validateFlow, detectFlowCycles, collectActivityIds } from "@lxpack/validators";
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`validateCourse` runs flow checks when `manifest.flow` is present: valid `goto` targets, known condition shapes, and cycle detection.
|
|
78
|
+
|
|
79
|
+
### Safe path resolution
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { resolveCoursePath, isPathContained } from "@lxpack/validators";
|
|
83
|
+
|
|
84
|
+
const abs = resolveCoursePath(courseDir, "lessons/intro.md");
|
|
85
|
+
isPathContained(courseDir, abs); // true if inside course root
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Exports
|
|
89
|
+
|
|
90
|
+
| Export | Description |
|
|
91
|
+
|--------|-------------|
|
|
92
|
+
| `validateCourse(dir)` | Parse `course.yaml`, validate schema, flow, files, symlink containment |
|
|
93
|
+
| `loadManifest(courseDir)` | Load and parse `course.yaml` |
|
|
94
|
+
| `buildRuntimeAssessmentBundle(dir, manifest)` | Load assessments; split learner view, keys, configs, feedback |
|
|
95
|
+
| `toLearnerAssessment(assessment)` | Strip `correct` from choices; extract config and feedback maps |
|
|
96
|
+
| `validateFlow(manifest)` | Flow rule and target validation |
|
|
97
|
+
| `detectFlowCycles(flow)` | Cycle detection for branching graphs |
|
|
98
|
+
| `collectActivityIds(manifest)` | Lesson and assessment IDs for flow targets |
|
|
99
|
+
| `conditionSchema`, `flowRuleSchema` | Zod schemas for flow conditions and rules |
|
|
100
|
+
| `BUILTIN_COMPONENT_IDS`, `isBuiltinComponentId` | Allowed built-in component lesson IDs |
|
|
101
|
+
| `resolveCoursePath(dir, relativePath)` | Resolve a path safely inside the course directory |
|
|
102
|
+
| `isPathContained(root, target)` | Whether `target` stays under `root` |
|
|
103
|
+
| `courseManifestSchema` | Zod schema for the full course manifest |
|
|
104
|
+
| `lessonSchema`, `assessmentSchema`, `variableDefSchema`, … | Strict sub-schemas |
|
|
105
|
+
| `CourseManifest`, `Lesson`, `Assessment`, `FlowRule`, `VariableDef`, `RuntimeAssessmentBundle` | TypeScript types |
|
|
106
|
+
|
|
107
|
+
## What gets validated
|
|
108
|
+
|
|
109
|
+
- Manifest shape: lessons, assessments, optional `variables` and `flow`, tracking rules
|
|
110
|
+
- Lesson types: `markdown` (`file`), `html` (`path`), `component` (`component` + optional `props`)
|
|
111
|
+
- Component IDs: built-in IDs or course overrides under `components/<id>/`
|
|
112
|
+
- Flow rules: condition grammar, `goto` targets that reference known activity IDs, acyclic flow (warnings for cycles)
|
|
113
|
+
- Assessment YAML: strict MCQ schemas; optional `maxAttempts`, `shuffleChoices`, `showFeedback`; `explanation` per question
|
|
114
|
+
- Duplicate lesson IDs
|
|
115
|
+
- Path containment — referenced files must stay inside the course directory (including via symlinks)
|
|
116
|
+
- On-disk assets: files exist and assessment paths are regular files
|
|
117
|
+
|
|
118
|
+
## Assessment packaging
|
|
119
|
+
|
|
120
|
+
Author assessments live as YAML under `assessments/` in the course repo. At build/preview time:
|
|
121
|
+
|
|
122
|
+
1. `buildRuntimeAssessmentBundle()` reads each assessment file.
|
|
123
|
+
2. **Learner payload** — questions and choices without `correct` flags.
|
|
124
|
+
3. **Answer keys** — `questionId → choiceId` map for scoring.
|
|
125
|
+
4. **Configs** — per-assessment quiz behavior (`maxAttempts`, `shuffleChoices`, `showFeedback`).
|
|
126
|
+
5. **Feedback** — `questionId → explanation` for immediate/end feedback (not shipped as separate files).
|
|
127
|
+
|
|
128
|
+
The CLI and [`@lxpack/scorm`](../scorm/README.md) embed all of this in the HTML config JSON. Exported ZIPs **do not** include `assessments/` files, so answer keys are not fetchable as static assets.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
From the monorepo root:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pnpm --filter @lxpack/validators build
|
|
136
|
+
pnpm --filter @lxpack/validators test
|
|
137
|
+
pnpm --filter @lxpack/validators typecheck
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Links
|
|
141
|
+
|
|
142
|
+
- [LXPack repository](https://github.com/eddiethedean/lxpack)
|
|
143
|
+
- [Documentation index](https://github.com/eddiethedean/lxpack/blob/main/docs/README.md)
|
|
144
|
+
- [Technical specification](https://github.com/eddiethedean/lxpack/blob/main/docs/SPEC.md)
|
|
145
|
+
- [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
Apache-2.0
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
type Condition = {
|
|
4
|
+
variable: {
|
|
5
|
+
eq: [string, string | number | boolean];
|
|
6
|
+
};
|
|
7
|
+
} | {
|
|
8
|
+
assessment: {
|
|
9
|
+
passed: string;
|
|
10
|
+
};
|
|
11
|
+
} | {
|
|
12
|
+
interaction: {
|
|
13
|
+
done: string;
|
|
14
|
+
};
|
|
15
|
+
} | {
|
|
16
|
+
all: Condition[];
|
|
17
|
+
} | {
|
|
18
|
+
any: Condition[];
|
|
19
|
+
};
|
|
20
|
+
declare const conditionSchema: z.ZodType<Condition>;
|
|
21
|
+
declare const flowRuleSchema: z.ZodObject<{
|
|
22
|
+
when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
|
|
23
|
+
goto: z.ZodString;
|
|
24
|
+
}, "strict", z.ZodTypeAny, {
|
|
25
|
+
when: Condition;
|
|
26
|
+
goto: string;
|
|
27
|
+
}, {
|
|
28
|
+
when: Condition;
|
|
29
|
+
goto: string;
|
|
30
|
+
}>;
|
|
31
|
+
type FlowRule = z.infer<typeof flowRuleSchema>;
|
|
32
|
+
|
|
3
33
|
declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
|
|
4
34
|
id: z.ZodString;
|
|
5
35
|
prompt: z.ZodString;
|
|
@@ -7,7 +37,7 @@ declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
|
|
|
7
37
|
id: z.ZodString;
|
|
8
38
|
text: z.ZodString;
|
|
9
39
|
correct: z.ZodOptional<z.ZodBoolean>;
|
|
10
|
-
}, "
|
|
40
|
+
}, "strict", z.ZodTypeAny, {
|
|
11
41
|
id: string;
|
|
12
42
|
text: string;
|
|
13
43
|
correct?: boolean | undefined;
|
|
@@ -17,7 +47,7 @@ declare const assessmentQuestionSchema: z.ZodEffects<z.ZodObject<{
|
|
|
17
47
|
correct?: boolean | undefined;
|
|
18
48
|
}>, "many">;
|
|
19
49
|
explanation: z.ZodOptional<z.ZodString>;
|
|
20
|
-
}, "
|
|
50
|
+
}, "strict", z.ZodTypeAny, {
|
|
21
51
|
id: string;
|
|
22
52
|
prompt: string;
|
|
23
53
|
choices: {
|
|
@@ -60,13 +90,13 @@ declare const markdownLessonSchema: z.ZodObject<{
|
|
|
60
90
|
file: z.ZodString;
|
|
61
91
|
title: z.ZodOptional<z.ZodString>;
|
|
62
92
|
}, "strict", z.ZodTypeAny, {
|
|
63
|
-
id: string;
|
|
64
93
|
type: "markdown";
|
|
94
|
+
id: string;
|
|
65
95
|
file: string;
|
|
66
96
|
title?: string | undefined;
|
|
67
97
|
}, {
|
|
68
|
-
id: string;
|
|
69
98
|
type: "markdown";
|
|
99
|
+
id: string;
|
|
70
100
|
file: string;
|
|
71
101
|
title?: string | undefined;
|
|
72
102
|
}>;
|
|
@@ -76,15 +106,34 @@ declare const htmlLessonSchema: z.ZodObject<{
|
|
|
76
106
|
path: z.ZodString;
|
|
77
107
|
title: z.ZodOptional<z.ZodString>;
|
|
78
108
|
}, "strict", z.ZodTypeAny, {
|
|
79
|
-
id: string;
|
|
80
109
|
path: string;
|
|
81
110
|
type: "html";
|
|
111
|
+
id: string;
|
|
82
112
|
title?: string | undefined;
|
|
83
113
|
}, {
|
|
84
|
-
id: string;
|
|
85
114
|
path: string;
|
|
86
115
|
type: "html";
|
|
116
|
+
id: string;
|
|
117
|
+
title?: string | undefined;
|
|
118
|
+
}>;
|
|
119
|
+
declare const componentLessonSchema: z.ZodObject<{
|
|
120
|
+
id: z.ZodString;
|
|
121
|
+
type: z.ZodLiteral<"component">;
|
|
122
|
+
component: z.ZodString;
|
|
123
|
+
props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
124
|
+
title: z.ZodOptional<z.ZodString>;
|
|
125
|
+
}, "strict", z.ZodTypeAny, {
|
|
126
|
+
type: "component";
|
|
127
|
+
id: string;
|
|
128
|
+
component: string;
|
|
129
|
+
title?: string | undefined;
|
|
130
|
+
props?: Record<string, unknown> | undefined;
|
|
131
|
+
}, {
|
|
132
|
+
type: "component";
|
|
133
|
+
id: string;
|
|
134
|
+
component: string;
|
|
87
135
|
title?: string | undefined;
|
|
136
|
+
props?: Record<string, unknown> | undefined;
|
|
88
137
|
}>;
|
|
89
138
|
declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
90
139
|
id: z.ZodString;
|
|
@@ -92,13 +141,13 @@ declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
92
141
|
file: z.ZodString;
|
|
93
142
|
title: z.ZodOptional<z.ZodString>;
|
|
94
143
|
}, "strict", z.ZodTypeAny, {
|
|
95
|
-
id: string;
|
|
96
144
|
type: "markdown";
|
|
145
|
+
id: string;
|
|
97
146
|
file: string;
|
|
98
147
|
title?: string | undefined;
|
|
99
148
|
}, {
|
|
100
|
-
id: string;
|
|
101
149
|
type: "markdown";
|
|
150
|
+
id: string;
|
|
102
151
|
file: string;
|
|
103
152
|
title?: string | undefined;
|
|
104
153
|
}>, z.ZodObject<{
|
|
@@ -107,20 +156,42 @@ declare const lessonSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
|
107
156
|
path: z.ZodString;
|
|
108
157
|
title: z.ZodOptional<z.ZodString>;
|
|
109
158
|
}, "strict", z.ZodTypeAny, {
|
|
110
|
-
id: string;
|
|
111
159
|
path: string;
|
|
112
160
|
type: "html";
|
|
161
|
+
id: string;
|
|
113
162
|
title?: string | undefined;
|
|
114
163
|
}, {
|
|
115
|
-
id: string;
|
|
116
164
|
path: string;
|
|
117
165
|
type: "html";
|
|
166
|
+
id: string;
|
|
167
|
+
title?: string | undefined;
|
|
168
|
+
}>, z.ZodObject<{
|
|
169
|
+
id: z.ZodString;
|
|
170
|
+
type: z.ZodLiteral<"component">;
|
|
171
|
+
component: z.ZodString;
|
|
172
|
+
props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
173
|
+
title: z.ZodOptional<z.ZodString>;
|
|
174
|
+
}, "strict", z.ZodTypeAny, {
|
|
175
|
+
type: "component";
|
|
176
|
+
id: string;
|
|
177
|
+
component: string;
|
|
118
178
|
title?: string | undefined;
|
|
179
|
+
props?: Record<string, unknown> | undefined;
|
|
180
|
+
}, {
|
|
181
|
+
type: "component";
|
|
182
|
+
id: string;
|
|
183
|
+
component: string;
|
|
184
|
+
title?: string | undefined;
|
|
185
|
+
props?: Record<string, unknown> | undefined;
|
|
119
186
|
}>]>;
|
|
187
|
+
declare const showFeedbackSchema: z.ZodDefault<z.ZodEnum<["immediate", "end", "never"]>>;
|
|
120
188
|
declare const assessmentSchema: z.ZodObject<{
|
|
121
189
|
id: z.ZodString;
|
|
122
190
|
title: z.ZodOptional<z.ZodString>;
|
|
123
191
|
passingScore: z.ZodDefault<z.ZodNumber>;
|
|
192
|
+
maxAttempts: z.ZodOptional<z.ZodNumber>;
|
|
193
|
+
shuffleChoices: z.ZodOptional<z.ZodBoolean>;
|
|
194
|
+
showFeedback: z.ZodOptional<z.ZodDefault<z.ZodEnum<["immediate", "end", "never"]>>>;
|
|
124
195
|
questions: z.ZodArray<z.ZodEffects<z.ZodObject<{
|
|
125
196
|
id: z.ZodString;
|
|
126
197
|
prompt: z.ZodString;
|
|
@@ -128,7 +199,7 @@ declare const assessmentSchema: z.ZodObject<{
|
|
|
128
199
|
id: z.ZodString;
|
|
129
200
|
text: z.ZodString;
|
|
130
201
|
correct: z.ZodOptional<z.ZodBoolean>;
|
|
131
|
-
}, "
|
|
202
|
+
}, "strict", z.ZodTypeAny, {
|
|
132
203
|
id: string;
|
|
133
204
|
text: string;
|
|
134
205
|
correct?: boolean | undefined;
|
|
@@ -138,7 +209,7 @@ declare const assessmentSchema: z.ZodObject<{
|
|
|
138
209
|
correct?: boolean | undefined;
|
|
139
210
|
}>, "many">;
|
|
140
211
|
explanation: z.ZodOptional<z.ZodString>;
|
|
141
|
-
}, "
|
|
212
|
+
}, "strict", z.ZodTypeAny, {
|
|
142
213
|
id: string;
|
|
143
214
|
prompt: string;
|
|
144
215
|
choices: {
|
|
@@ -189,6 +260,9 @@ declare const assessmentSchema: z.ZodObject<{
|
|
|
189
260
|
explanation?: string | undefined;
|
|
190
261
|
}[];
|
|
191
262
|
title?: string | undefined;
|
|
263
|
+
maxAttempts?: number | undefined;
|
|
264
|
+
shuffleChoices?: boolean | undefined;
|
|
265
|
+
showFeedback?: "never" | "immediate" | "end" | undefined;
|
|
192
266
|
}, {
|
|
193
267
|
id: string;
|
|
194
268
|
questions: {
|
|
@@ -203,6 +277,9 @@ declare const assessmentSchema: z.ZodObject<{
|
|
|
203
277
|
}[];
|
|
204
278
|
title?: string | undefined;
|
|
205
279
|
passingScore?: number | undefined;
|
|
280
|
+
maxAttempts?: number | undefined;
|
|
281
|
+
shuffleChoices?: boolean | undefined;
|
|
282
|
+
showFeedback?: "never" | "immediate" | "end" | undefined;
|
|
206
283
|
}>;
|
|
207
284
|
declare const assessmentRefSchema: z.ZodObject<{
|
|
208
285
|
id: z.ZodString;
|
|
@@ -214,6 +291,16 @@ declare const assessmentRefSchema: z.ZodObject<{
|
|
|
214
291
|
id: string;
|
|
215
292
|
file: string;
|
|
216
293
|
}>;
|
|
294
|
+
declare const variableDefSchema: z.ZodObject<{
|
|
295
|
+
default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
296
|
+
type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
|
|
297
|
+
}, "strict", z.ZodTypeAny, {
|
|
298
|
+
default: string | number | boolean;
|
|
299
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
300
|
+
}, {
|
|
301
|
+
default: string | number | boolean;
|
|
302
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
303
|
+
}>;
|
|
217
304
|
declare const trackingSchema: z.ZodOptional<z.ZodObject<{
|
|
218
305
|
completion: z.ZodOptional<z.ZodObject<{
|
|
219
306
|
threshold: z.ZodDefault<z.ZodNumber>;
|
|
@@ -238,6 +325,7 @@ declare const runtimeConfigSchema: z.ZodOptional<z.ZodObject<{
|
|
|
238
325
|
}, {
|
|
239
326
|
theme?: string | undefined;
|
|
240
327
|
}>>;
|
|
328
|
+
|
|
241
329
|
declare const courseManifestSchema: z.ZodObject<{
|
|
242
330
|
title: z.ZodString;
|
|
243
331
|
version: z.ZodString;
|
|
@@ -266,19 +354,39 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
266
354
|
threshold?: number | undefined;
|
|
267
355
|
} | undefined;
|
|
268
356
|
}>>;
|
|
357
|
+
variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
358
|
+
default: z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean]>;
|
|
359
|
+
type: z.ZodOptional<z.ZodEnum<["string", "number", "boolean"]>>;
|
|
360
|
+
}, "strict", z.ZodTypeAny, {
|
|
361
|
+
default: string | number | boolean;
|
|
362
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
363
|
+
}, {
|
|
364
|
+
default: string | number | boolean;
|
|
365
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
366
|
+
}>>>;
|
|
367
|
+
flow: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
368
|
+
when: z.ZodType<Condition, z.ZodTypeDef, Condition>;
|
|
369
|
+
goto: z.ZodString;
|
|
370
|
+
}, "strict", z.ZodTypeAny, {
|
|
371
|
+
when: Condition;
|
|
372
|
+
goto: string;
|
|
373
|
+
}, {
|
|
374
|
+
when: Condition;
|
|
375
|
+
goto: string;
|
|
376
|
+
}>, "many">>;
|
|
269
377
|
lessons: z.ZodArray<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
270
378
|
id: z.ZodString;
|
|
271
379
|
type: z.ZodLiteral<"markdown">;
|
|
272
380
|
file: z.ZodString;
|
|
273
381
|
title: z.ZodOptional<z.ZodString>;
|
|
274
382
|
}, "strict", z.ZodTypeAny, {
|
|
275
|
-
id: string;
|
|
276
383
|
type: "markdown";
|
|
384
|
+
id: string;
|
|
277
385
|
file: string;
|
|
278
386
|
title?: string | undefined;
|
|
279
387
|
}, {
|
|
280
|
-
id: string;
|
|
281
388
|
type: "markdown";
|
|
389
|
+
id: string;
|
|
282
390
|
file: string;
|
|
283
391
|
title?: string | undefined;
|
|
284
392
|
}>, z.ZodObject<{
|
|
@@ -287,15 +395,33 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
287
395
|
path: z.ZodString;
|
|
288
396
|
title: z.ZodOptional<z.ZodString>;
|
|
289
397
|
}, "strict", z.ZodTypeAny, {
|
|
290
|
-
id: string;
|
|
291
398
|
path: string;
|
|
292
399
|
type: "html";
|
|
400
|
+
id: string;
|
|
293
401
|
title?: string | undefined;
|
|
294
402
|
}, {
|
|
295
|
-
id: string;
|
|
296
403
|
path: string;
|
|
297
404
|
type: "html";
|
|
405
|
+
id: string;
|
|
298
406
|
title?: string | undefined;
|
|
407
|
+
}>, z.ZodObject<{
|
|
408
|
+
id: z.ZodString;
|
|
409
|
+
type: z.ZodLiteral<"component">;
|
|
410
|
+
component: z.ZodString;
|
|
411
|
+
props: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
412
|
+
title: z.ZodOptional<z.ZodString>;
|
|
413
|
+
}, "strict", z.ZodTypeAny, {
|
|
414
|
+
type: "component";
|
|
415
|
+
id: string;
|
|
416
|
+
component: string;
|
|
417
|
+
title?: string | undefined;
|
|
418
|
+
props?: Record<string, unknown> | undefined;
|
|
419
|
+
}, {
|
|
420
|
+
type: "component";
|
|
421
|
+
id: string;
|
|
422
|
+
component: string;
|
|
423
|
+
title?: string | undefined;
|
|
424
|
+
props?: Record<string, unknown> | undefined;
|
|
299
425
|
}>]>, "many">;
|
|
300
426
|
assessments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
301
427
|
id: z.ZodString;
|
|
@@ -311,15 +437,21 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
311
437
|
title: string;
|
|
312
438
|
version: string;
|
|
313
439
|
lessons: ({
|
|
314
|
-
id: string;
|
|
315
440
|
type: "markdown";
|
|
441
|
+
id: string;
|
|
316
442
|
file: string;
|
|
317
443
|
title?: string | undefined;
|
|
318
444
|
} | {
|
|
319
|
-
id: string;
|
|
320
445
|
path: string;
|
|
321
446
|
type: "html";
|
|
447
|
+
id: string;
|
|
448
|
+
title?: string | undefined;
|
|
449
|
+
} | {
|
|
450
|
+
type: "component";
|
|
451
|
+
id: string;
|
|
452
|
+
component: string;
|
|
322
453
|
title?: string | undefined;
|
|
454
|
+
props?: Record<string, unknown> | undefined;
|
|
323
455
|
})[];
|
|
324
456
|
description?: string | undefined;
|
|
325
457
|
runtime?: {
|
|
@@ -330,6 +462,14 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
330
462
|
threshold: number;
|
|
331
463
|
} | undefined;
|
|
332
464
|
} | undefined;
|
|
465
|
+
variables?: Record<string, {
|
|
466
|
+
default: string | number | boolean;
|
|
467
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
468
|
+
}> | undefined;
|
|
469
|
+
flow?: {
|
|
470
|
+
when: Condition;
|
|
471
|
+
goto: string;
|
|
472
|
+
}[] | undefined;
|
|
333
473
|
assessments?: {
|
|
334
474
|
id: string;
|
|
335
475
|
file: string;
|
|
@@ -338,15 +478,21 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
338
478
|
title: string;
|
|
339
479
|
version: string;
|
|
340
480
|
lessons: ({
|
|
341
|
-
id: string;
|
|
342
481
|
type: "markdown";
|
|
482
|
+
id: string;
|
|
343
483
|
file: string;
|
|
344
484
|
title?: string | undefined;
|
|
345
485
|
} | {
|
|
346
|
-
id: string;
|
|
347
486
|
path: string;
|
|
348
487
|
type: "html";
|
|
488
|
+
id: string;
|
|
349
489
|
title?: string | undefined;
|
|
490
|
+
} | {
|
|
491
|
+
type: "component";
|
|
492
|
+
id: string;
|
|
493
|
+
component: string;
|
|
494
|
+
title?: string | undefined;
|
|
495
|
+
props?: Record<string, unknown> | undefined;
|
|
350
496
|
})[];
|
|
351
497
|
description?: string | undefined;
|
|
352
498
|
runtime?: {
|
|
@@ -357,6 +503,14 @@ declare const courseManifestSchema: z.ZodObject<{
|
|
|
357
503
|
threshold?: number | undefined;
|
|
358
504
|
} | undefined;
|
|
359
505
|
} | undefined;
|
|
506
|
+
variables?: Record<string, {
|
|
507
|
+
default: string | number | boolean;
|
|
508
|
+
type?: "string" | "number" | "boolean" | undefined;
|
|
509
|
+
}> | undefined;
|
|
510
|
+
flow?: {
|
|
511
|
+
when: Condition;
|
|
512
|
+
goto: string;
|
|
513
|
+
}[] | undefined;
|
|
360
514
|
assessments?: {
|
|
361
515
|
id: string;
|
|
362
516
|
file: string;
|
|
@@ -366,6 +520,14 @@ type CourseManifest = z.infer<typeof courseManifestSchema>;
|
|
|
366
520
|
type Lesson = z.infer<typeof lessonSchema>;
|
|
367
521
|
type Assessment = z.infer<typeof assessmentSchema>;
|
|
368
522
|
type AssessmentRef = z.infer<typeof assessmentRefSchema>;
|
|
523
|
+
type VariableDef = z.infer<typeof variableDefSchema>;
|
|
524
|
+
type ComponentLesson = z.infer<typeof componentLessonSchema>;
|
|
525
|
+
type ShowFeedback = z.infer<typeof showFeedbackSchema>;
|
|
526
|
+
|
|
527
|
+
/** Built-in component ids shipped in @lxpack/components */
|
|
528
|
+
declare const BUILTIN_COMPONENT_IDS: readonly ["callout", "image-card", "checklist"];
|
|
529
|
+
type BuiltinComponentId = (typeof BUILTIN_COMPONENT_IDS)[number];
|
|
530
|
+
declare function isBuiltinComponentId(id: string): id is BuiltinComponentId;
|
|
369
531
|
|
|
370
532
|
declare function formatErrorMessage(err: unknown): string;
|
|
371
533
|
declare function formatIssuePath(path: PropertyKey[]): string;
|
|
@@ -379,6 +541,7 @@ interface ValidationResult {
|
|
|
379
541
|
manifest?: CourseManifest;
|
|
380
542
|
issues: ValidationIssue[];
|
|
381
543
|
}
|
|
544
|
+
declare function isPathContained(rootDir: string, candidatePath: string): boolean;
|
|
382
545
|
declare function resolveCoursePath(courseDir: string, relativePath: string): {
|
|
383
546
|
ok: true;
|
|
384
547
|
path: string;
|
|
@@ -386,10 +549,58 @@ declare function resolveCoursePath(courseDir: string, relativePath: string): {
|
|
|
386
549
|
ok: false;
|
|
387
550
|
message: string;
|
|
388
551
|
};
|
|
552
|
+
declare function assertResolvedPathContained(courseDir: string, resolvedPath: string): {
|
|
553
|
+
ok: true;
|
|
554
|
+
} | {
|
|
555
|
+
ok: false;
|
|
556
|
+
message: string;
|
|
557
|
+
};
|
|
389
558
|
declare function loadManifest(courseDir: string): Promise<{
|
|
390
559
|
manifest: CourseManifest;
|
|
391
560
|
raw: unknown;
|
|
392
561
|
} | ValidationIssue[]>;
|
|
393
562
|
declare function validateCourse(courseDir: string): Promise<ValidationResult>;
|
|
394
563
|
|
|
395
|
-
|
|
564
|
+
declare function collectActivityIds(manifest: CourseManifest): Set<string>;
|
|
565
|
+
declare function validateFlow(manifest: CourseManifest): ValidationIssue[];
|
|
566
|
+
/** Detect simple goto cycles (same-target chains). */
|
|
567
|
+
declare function detectFlowCycles(flow: FlowRule[]): string[];
|
|
568
|
+
|
|
569
|
+
interface LearnerChoice {
|
|
570
|
+
id: string;
|
|
571
|
+
text: string;
|
|
572
|
+
}
|
|
573
|
+
interface LearnerQuestion {
|
|
574
|
+
id: string;
|
|
575
|
+
prompt: string;
|
|
576
|
+
choices: LearnerChoice[];
|
|
577
|
+
}
|
|
578
|
+
interface LearnerAssessment {
|
|
579
|
+
id: string;
|
|
580
|
+
title?: string;
|
|
581
|
+
passingScore: number;
|
|
582
|
+
questions: LearnerQuestion[];
|
|
583
|
+
}
|
|
584
|
+
interface AssessmentRuntimeConfig {
|
|
585
|
+
maxAttempts: number;
|
|
586
|
+
shuffleChoices: boolean;
|
|
587
|
+
showFeedback: ShowFeedback;
|
|
588
|
+
}
|
|
589
|
+
interface QuestionFeedback {
|
|
590
|
+
[questionId: string]: string | undefined;
|
|
591
|
+
}
|
|
592
|
+
interface RuntimeAssessmentBundle {
|
|
593
|
+
assessments: Record<string, LearnerAssessment>;
|
|
594
|
+
answerKeys: Record<string, Record<string, string>>;
|
|
595
|
+
configs: Record<string, AssessmentRuntimeConfig>;
|
|
596
|
+
feedback: Record<string, QuestionFeedback>;
|
|
597
|
+
}
|
|
598
|
+
declare function toLearnerAssessment(assessment: Assessment): {
|
|
599
|
+
learner: LearnerAssessment;
|
|
600
|
+
answerKey: Record<string, string>;
|
|
601
|
+
config: AssessmentRuntimeConfig;
|
|
602
|
+
feedback: QuestionFeedback;
|
|
603
|
+
};
|
|
604
|
+
declare function buildRuntimeAssessmentBundle(courseDir: string, manifest: CourseManifest): Promise<RuntimeAssessmentBundle>;
|
|
605
|
+
|
|
606
|
+
export { type Assessment, type AssessmentRef, type AssessmentRuntimeConfig, BUILTIN_COMPONENT_IDS, type BuiltinComponentId, type ComponentLesson, type Condition, type CourseManifest, type FlowRule, type LearnerAssessment, type LearnerChoice, type LearnerQuestion, type Lesson, type QuestionFeedback, type RuntimeAssessmentBundle, type ShowFeedback, type ValidationIssue, type ValidationResult, type VariableDef, assertResolvedPathContained, assessmentQuestionSchema, assessmentRefSchema, assessmentSchema, buildRuntimeAssessmentBundle, collectActivityIds, componentLessonSchema, conditionSchema, courseManifestSchema, detectFlowCycles, flowRuleSchema, formatErrorMessage, formatIssuePath, htmlLessonSchema, isBuiltinComponentId, isPathContained, lessonSchema, loadManifest, markdownLessonSchema, resolveCoursePath, runtimeConfigSchema, showFeedbackSchema, toLearnerAssessment, trackingSchema, validateCourse, validateFlow, variableDefSchema };
|
package/dist/index.js
CHANGED
|
@@ -1,72 +1,243 @@
|
|
|
1
1
|
// src/schemas.ts
|
|
2
|
+
import { z as z2 } from "zod";
|
|
3
|
+
|
|
4
|
+
// src/conditions.ts
|
|
2
5
|
import { z } from "zod";
|
|
3
|
-
var
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
var baseConditionSchema = z.union([
|
|
7
|
+
z.object({
|
|
8
|
+
variable: z.object({
|
|
9
|
+
eq: z.tuple([
|
|
10
|
+
z.string().min(1),
|
|
11
|
+
z.union([z.string(), z.number(), z.boolean()])
|
|
12
|
+
])
|
|
13
|
+
})
|
|
14
|
+
}).strict(),
|
|
15
|
+
z.object({
|
|
16
|
+
assessment: z.object({
|
|
17
|
+
passed: z.string().min(1)
|
|
18
|
+
})
|
|
19
|
+
}).strict(),
|
|
20
|
+
z.object({
|
|
21
|
+
interaction: z.object({
|
|
22
|
+
done: z.string().min(1)
|
|
23
|
+
})
|
|
24
|
+
}).strict()
|
|
25
|
+
]);
|
|
26
|
+
var conditionSchema = z.lazy(
|
|
27
|
+
() => z.union([
|
|
28
|
+
...baseConditionSchema.options,
|
|
29
|
+
z.object({ all: z.array(conditionSchema) }).strict(),
|
|
30
|
+
z.object({ any: z.array(conditionSchema) }).strict()
|
|
31
|
+
])
|
|
32
|
+
);
|
|
33
|
+
var flowRuleSchema = z.object({
|
|
34
|
+
when: conditionSchema,
|
|
35
|
+
goto: z.string().min(1)
|
|
36
|
+
}).strict();
|
|
37
|
+
|
|
38
|
+
// src/schemas.ts
|
|
39
|
+
var choiceSchema = z2.object({
|
|
40
|
+
id: z2.string().min(1),
|
|
41
|
+
text: z2.string().min(1),
|
|
42
|
+
correct: z2.boolean().optional()
|
|
43
|
+
}).strict();
|
|
44
|
+
var assessmentQuestionSchema = z2.object({
|
|
45
|
+
id: z2.string().min(1),
|
|
46
|
+
prompt: z2.string().min(1),
|
|
47
|
+
choices: z2.array(choiceSchema).min(1),
|
|
48
|
+
explanation: z2.string().optional()
|
|
49
|
+
}).strict().superRefine((question, ctx) => {
|
|
14
50
|
const correctCount = question.choices.filter((c) => c.correct === true).length;
|
|
15
51
|
if (correctCount !== 1) {
|
|
16
52
|
ctx.addIssue({
|
|
17
|
-
code:
|
|
53
|
+
code: z2.ZodIssueCode.custom,
|
|
18
54
|
message: "Each question must have exactly one correct choice",
|
|
19
55
|
path: ["choices"]
|
|
20
56
|
});
|
|
21
57
|
}
|
|
22
58
|
});
|
|
23
|
-
var markdownLessonSchema =
|
|
24
|
-
id:
|
|
25
|
-
type:
|
|
26
|
-
file:
|
|
27
|
-
title:
|
|
59
|
+
var markdownLessonSchema = z2.object({
|
|
60
|
+
id: z2.string().min(1),
|
|
61
|
+
type: z2.literal("markdown"),
|
|
62
|
+
file: z2.string().min(1),
|
|
63
|
+
title: z2.string().optional()
|
|
28
64
|
}).strict();
|
|
29
|
-
var htmlLessonSchema =
|
|
30
|
-
id:
|
|
31
|
-
type:
|
|
32
|
-
path:
|
|
33
|
-
title:
|
|
65
|
+
var htmlLessonSchema = z2.object({
|
|
66
|
+
id: z2.string().min(1),
|
|
67
|
+
type: z2.literal("html"),
|
|
68
|
+
path: z2.string().min(1),
|
|
69
|
+
title: z2.string().optional()
|
|
34
70
|
}).strict();
|
|
35
|
-
var
|
|
71
|
+
var componentLessonSchema = z2.object({
|
|
72
|
+
id: z2.string().min(1),
|
|
73
|
+
type: z2.literal("component"),
|
|
74
|
+
component: z2.string().min(1),
|
|
75
|
+
props: z2.record(z2.unknown()).optional(),
|
|
76
|
+
title: z2.string().optional()
|
|
77
|
+
}).strict();
|
|
78
|
+
var lessonSchema = z2.discriminatedUnion("type", [
|
|
36
79
|
markdownLessonSchema,
|
|
37
|
-
htmlLessonSchema
|
|
80
|
+
htmlLessonSchema,
|
|
81
|
+
componentLessonSchema
|
|
38
82
|
]);
|
|
39
|
-
var
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
var showFeedbackSchema = z2.enum(["immediate", "end", "never"]).default("never");
|
|
84
|
+
var assessmentSchema = z2.object({
|
|
85
|
+
id: z2.string().min(1),
|
|
86
|
+
title: z2.string().optional(),
|
|
87
|
+
passingScore: z2.number().min(0).max(1).default(0.7),
|
|
88
|
+
maxAttempts: z2.number().int().min(1).optional(),
|
|
89
|
+
shuffleChoices: z2.boolean().optional(),
|
|
90
|
+
showFeedback: showFeedbackSchema.optional(),
|
|
91
|
+
questions: z2.array(assessmentQuestionSchema).min(1)
|
|
44
92
|
}).strict();
|
|
45
|
-
var assessmentRefSchema =
|
|
46
|
-
id:
|
|
47
|
-
file:
|
|
93
|
+
var assessmentRefSchema = z2.object({
|
|
94
|
+
id: z2.string().min(1),
|
|
95
|
+
file: z2.string().min(1)
|
|
48
96
|
}).strict();
|
|
49
|
-
var
|
|
50
|
-
|
|
51
|
-
|
|
97
|
+
var variableDefSchema = z2.object({
|
|
98
|
+
default: z2.union([z2.string(), z2.number(), z2.boolean()]),
|
|
99
|
+
type: z2.enum(["string", "number", "boolean"]).optional()
|
|
100
|
+
}).strict();
|
|
101
|
+
var trackingSchema = z2.object({
|
|
102
|
+
completion: z2.object({
|
|
103
|
+
threshold: z2.number().min(0).max(1).default(0.9)
|
|
52
104
|
}).strict().optional()
|
|
53
105
|
}).strict().optional();
|
|
54
|
-
var runtimeConfigSchema =
|
|
55
|
-
theme:
|
|
106
|
+
var runtimeConfigSchema = z2.object({
|
|
107
|
+
theme: z2.string().default("modern")
|
|
56
108
|
}).strict().optional();
|
|
57
|
-
var courseManifestSchema =
|
|
58
|
-
title:
|
|
59
|
-
version:
|
|
60
|
-
description:
|
|
109
|
+
var courseManifestSchema = z2.object({
|
|
110
|
+
title: z2.string().min(1),
|
|
111
|
+
version: z2.string().min(1),
|
|
112
|
+
description: z2.string().optional(),
|
|
61
113
|
runtime: runtimeConfigSchema,
|
|
62
114
|
tracking: trackingSchema,
|
|
63
|
-
|
|
64
|
-
|
|
115
|
+
variables: z2.record(variableDefSchema).optional(),
|
|
116
|
+
flow: z2.array(flowRuleSchema).optional(),
|
|
117
|
+
lessons: z2.array(lessonSchema).min(1),
|
|
118
|
+
assessments: z2.array(assessmentRefSchema).optional()
|
|
65
119
|
}).strict();
|
|
66
120
|
|
|
121
|
+
// src/components.ts
|
|
122
|
+
var BUILTIN_COMPONENT_IDS = [
|
|
123
|
+
"callout",
|
|
124
|
+
"image-card",
|
|
125
|
+
"checklist"
|
|
126
|
+
];
|
|
127
|
+
function isBuiltinComponentId(id) {
|
|
128
|
+
return BUILTIN_COMPONENT_IDS.includes(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/flow-validate.ts
|
|
132
|
+
function collectActivityIds(manifest) {
|
|
133
|
+
const ids = /* @__PURE__ */ new Set();
|
|
134
|
+
for (const lesson of manifest.lessons) {
|
|
135
|
+
ids.add(lesson.id);
|
|
136
|
+
}
|
|
137
|
+
for (const ref of manifest.assessments ?? []) {
|
|
138
|
+
ids.add(ref.id);
|
|
139
|
+
}
|
|
140
|
+
return ids;
|
|
141
|
+
}
|
|
142
|
+
function collectConditionRefs(condition, refs) {
|
|
143
|
+
if ("variable" in condition && condition.variable?.eq) {
|
|
144
|
+
refs.variables.add(condition.variable.eq[0]);
|
|
145
|
+
}
|
|
146
|
+
if ("assessment" in condition && condition.assessment?.passed) {
|
|
147
|
+
refs.assessments.add(condition.assessment.passed);
|
|
148
|
+
}
|
|
149
|
+
if ("interaction" in condition && condition.interaction?.done) {
|
|
150
|
+
refs.interactions.add(condition.interaction.done);
|
|
151
|
+
}
|
|
152
|
+
if ("all" in condition && condition.all) {
|
|
153
|
+
for (const c of condition.all) collectConditionRefs(c, refs);
|
|
154
|
+
}
|
|
155
|
+
if ("any" in condition && condition.any) {
|
|
156
|
+
for (const c of condition.any) collectConditionRefs(c, refs);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function validateFlow(manifest) {
|
|
160
|
+
const issues = [];
|
|
161
|
+
const flow = manifest.flow;
|
|
162
|
+
if (!flow?.length) return issues;
|
|
163
|
+
const activityIds = collectActivityIds(manifest);
|
|
164
|
+
const manifestVars = new Set(Object.keys(manifest.variables ?? {}));
|
|
165
|
+
flow.forEach((rule, index) => {
|
|
166
|
+
const path = `flow[${index}]`;
|
|
167
|
+
if (!activityIds.has(rule.goto)) {
|
|
168
|
+
issues.push({
|
|
169
|
+
path: `${path}.goto`,
|
|
170
|
+
message: `Unknown activity id: ${rule.goto}`,
|
|
171
|
+
severity: "error"
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const refs = {
|
|
175
|
+
variables: /* @__PURE__ */ new Set(),
|
|
176
|
+
assessments: /* @__PURE__ */ new Set(),
|
|
177
|
+
interactions: /* @__PURE__ */ new Set()
|
|
178
|
+
};
|
|
179
|
+
collectConditionRefs(rule.when, refs);
|
|
180
|
+
for (const v of refs.variables) {
|
|
181
|
+
if (!manifestVars.has(v)) {
|
|
182
|
+
issues.push({
|
|
183
|
+
path: `${path}.when`,
|
|
184
|
+
message: `Unknown variable in condition: ${v}`,
|
|
185
|
+
severity: "error"
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const a of refs.assessments) {
|
|
190
|
+
if (!activityIds.has(a)) {
|
|
191
|
+
issues.push({
|
|
192
|
+
path: `${path}.when`,
|
|
193
|
+
message: `Unknown assessment in condition: ${a}`,
|
|
194
|
+
severity: "error"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const i of refs.interactions) {
|
|
199
|
+
if (!activityIds.has(i)) {
|
|
200
|
+
issues.push({
|
|
201
|
+
path: `${path}.when`,
|
|
202
|
+
message: `Unknown interaction/lesson id in condition: ${i}`,
|
|
203
|
+
severity: "warning"
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return issues;
|
|
209
|
+
}
|
|
210
|
+
function detectFlowCycles(flow) {
|
|
211
|
+
const gotoOf = /* @__PURE__ */ new Map();
|
|
212
|
+
flow.forEach((rule, i) => gotoOf.set(i, rule.goto));
|
|
213
|
+
const errors = [];
|
|
214
|
+
const visited = /* @__PURE__ */ new Set();
|
|
215
|
+
for (let start = 0; start < flow.length; start++) {
|
|
216
|
+
if (visited.has(start)) continue;
|
|
217
|
+
const chain = /* @__PURE__ */ new Set();
|
|
218
|
+
let i = start;
|
|
219
|
+
while (i !== void 0 && i < flow.length) {
|
|
220
|
+
const ruleIndex = i;
|
|
221
|
+
if (chain.has(ruleIndex)) {
|
|
222
|
+
errors.push(`Flow rule cycle detected involving flow[${ruleIndex}]`);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (visited.has(ruleIndex)) break;
|
|
226
|
+
chain.add(ruleIndex);
|
|
227
|
+
visited.add(ruleIndex);
|
|
228
|
+
const target = gotoOf.get(ruleIndex);
|
|
229
|
+
const nextIdx = flow.findIndex(
|
|
230
|
+
(_, idx) => idx > ruleIndex && flow[idx].goto === target
|
|
231
|
+
);
|
|
232
|
+
i = nextIdx >= 0 ? nextIdx : void 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return errors;
|
|
236
|
+
}
|
|
237
|
+
|
|
67
238
|
// src/validate.ts
|
|
68
|
-
import { existsSync, statSync } from "fs";
|
|
69
|
-
import { join,
|
|
239
|
+
import { existsSync, realpathSync, statSync } from "fs";
|
|
240
|
+
import { isAbsolute, join, relative, resolve } from "path";
|
|
70
241
|
import { parse as parseYaml } from "yaml";
|
|
71
242
|
import { readFile } from "fs/promises";
|
|
72
243
|
function formatErrorMessage(err) {
|
|
@@ -76,20 +247,39 @@ function formatIssuePath(path) {
|
|
|
76
247
|
const joined = path.map(String).join(".");
|
|
77
248
|
return joined || "course.yaml";
|
|
78
249
|
}
|
|
250
|
+
function isPathContained(rootDir, candidatePath) {
|
|
251
|
+
const root = resolve(rootDir);
|
|
252
|
+
const candidate = resolve(candidatePath);
|
|
253
|
+
const rel = relative(root, candidate);
|
|
254
|
+
if (rel === "") return true;
|
|
255
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
256
|
+
}
|
|
79
257
|
function resolveCoursePath(courseDir, relativePath) {
|
|
80
258
|
if (relativePath.startsWith("/") || /^[a-zA-Z]:\\/.test(relativePath)) {
|
|
81
259
|
return { ok: false, message: "Absolute paths are not allowed" };
|
|
82
260
|
}
|
|
83
261
|
const resolvedDir = resolve(courseDir);
|
|
84
262
|
const resolvedPath = resolve(resolvedDir, relativePath);
|
|
85
|
-
|
|
86
|
-
if (!resolvedPath.startsWith(prefix)) {
|
|
263
|
+
if (!isPathContained(resolvedDir, resolvedPath)) {
|
|
87
264
|
return { ok: false, message: "Path escapes course directory" };
|
|
88
265
|
}
|
|
89
266
|
return { ok: true, path: resolvedPath };
|
|
90
267
|
}
|
|
268
|
+
function assertResolvedPathContained(courseDir, resolvedPath) {
|
|
269
|
+
try {
|
|
270
|
+
const root = realpathSync(resolve(courseDir));
|
|
271
|
+
const target = realpathSync(resolvedPath);
|
|
272
|
+
if (!isPathContained(root, target)) {
|
|
273
|
+
return { ok: false, message: "Path escapes course directory" };
|
|
274
|
+
}
|
|
275
|
+
return { ok: true };
|
|
276
|
+
} catch {
|
|
277
|
+
return { ok: false, message: "Path could not be resolved" };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
91
280
|
async function loadManifest(courseDir) {
|
|
92
|
-
const
|
|
281
|
+
const resolvedDir = resolve(courseDir);
|
|
282
|
+
const manifestPath = join(resolvedDir, "course.yaml");
|
|
93
283
|
if (!existsSync(manifestPath)) {
|
|
94
284
|
return [
|
|
95
285
|
{
|
|
@@ -149,6 +339,15 @@ async function validateCourse(courseDir) {
|
|
|
149
339
|
});
|
|
150
340
|
continue;
|
|
151
341
|
}
|
|
342
|
+
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
343
|
+
if (!contained.ok) {
|
|
344
|
+
issues.push({
|
|
345
|
+
path: `lessons.${lesson.id}.file`,
|
|
346
|
+
message: contained.message,
|
|
347
|
+
severity: "error"
|
|
348
|
+
});
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
152
351
|
const stat = statSync(resolved.path);
|
|
153
352
|
if (!stat.isFile()) {
|
|
154
353
|
issues.push({
|
|
@@ -157,6 +356,37 @@ async function validateCourse(courseDir) {
|
|
|
157
356
|
severity: "error"
|
|
158
357
|
});
|
|
159
358
|
}
|
|
359
|
+
} else if (lesson.type === "component") {
|
|
360
|
+
if (!isBuiltinComponentId(lesson.component)) {
|
|
361
|
+
const resolved = resolveCoursePath(
|
|
362
|
+
resolvedDir,
|
|
363
|
+
join("components", lesson.component)
|
|
364
|
+
);
|
|
365
|
+
if (!resolved.ok) {
|
|
366
|
+
issues.push({
|
|
367
|
+
path: `lessons.${lesson.id}.component`,
|
|
368
|
+
message: resolved.message,
|
|
369
|
+
severity: "error"
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (!existsSync(resolved.path)) {
|
|
374
|
+
issues.push({
|
|
375
|
+
path: `lessons.${lesson.id}.component`,
|
|
376
|
+
message: `Unknown component "${lesson.component}" and no override at components/${lesson.component}`,
|
|
377
|
+
severity: "error"
|
|
378
|
+
});
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
382
|
+
if (!contained.ok) {
|
|
383
|
+
issues.push({
|
|
384
|
+
path: `lessons.${lesson.id}.component`,
|
|
385
|
+
message: contained.message,
|
|
386
|
+
severity: "error"
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
160
390
|
} else if (lesson.type === "html") {
|
|
161
391
|
const resolved = resolveCoursePath(resolvedDir, lesson.path);
|
|
162
392
|
if (!resolved.ok) {
|
|
@@ -175,6 +405,15 @@ async function validateCourse(courseDir) {
|
|
|
175
405
|
});
|
|
176
406
|
continue;
|
|
177
407
|
}
|
|
408
|
+
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
409
|
+
if (!contained.ok) {
|
|
410
|
+
issues.push({
|
|
411
|
+
path: `lessons.${lesson.id}.path`,
|
|
412
|
+
message: contained.message,
|
|
413
|
+
severity: "error"
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
178
417
|
const stat = statSync(resolved.path);
|
|
179
418
|
if (!stat.isDirectory()) {
|
|
180
419
|
issues.push({
|
|
@@ -191,12 +430,24 @@ async function validateCourse(courseDir) {
|
|
|
191
430
|
message: `HTML interaction missing index.html: ${lesson.path}`,
|
|
192
431
|
severity: "error"
|
|
193
432
|
});
|
|
194
|
-
} else
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
433
|
+
} else {
|
|
434
|
+
const indexContained = assertResolvedPathContained(
|
|
435
|
+
resolvedDir,
|
|
436
|
+
indexPath
|
|
437
|
+
);
|
|
438
|
+
if (!indexContained.ok) {
|
|
439
|
+
issues.push({
|
|
440
|
+
path: `lessons.${lesson.id}.path`,
|
|
441
|
+
message: indexContained.message,
|
|
442
|
+
severity: "error"
|
|
443
|
+
});
|
|
444
|
+
} else if (!statSync(indexPath).isFile()) {
|
|
445
|
+
issues.push({
|
|
446
|
+
path: `lessons.${lesson.id}.path`,
|
|
447
|
+
message: `index.html is not a file: ${lesson.path}/index.html`,
|
|
448
|
+
severity: "error"
|
|
449
|
+
});
|
|
450
|
+
}
|
|
200
451
|
}
|
|
201
452
|
}
|
|
202
453
|
}
|
|
@@ -228,6 +479,24 @@ async function validateCourse(courseDir) {
|
|
|
228
479
|
});
|
|
229
480
|
continue;
|
|
230
481
|
}
|
|
482
|
+
const contained = assertResolvedPathContained(resolvedDir, resolved.path);
|
|
483
|
+
if (!contained.ok) {
|
|
484
|
+
issues.push({
|
|
485
|
+
path: `assessments.${ref.id}.file`,
|
|
486
|
+
message: contained.message,
|
|
487
|
+
severity: "error"
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const assessmentStat = statSync(resolved.path);
|
|
492
|
+
if (!assessmentStat.isFile()) {
|
|
493
|
+
issues.push({
|
|
494
|
+
path: `assessments.${ref.id}.file`,
|
|
495
|
+
message: `Assessment path is not a file: ${ref.file}`,
|
|
496
|
+
severity: "error"
|
|
497
|
+
});
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
231
500
|
try {
|
|
232
501
|
const content = await readFile(resolved.path, "utf-8");
|
|
233
502
|
const raw = parseYaml(content);
|
|
@@ -259,13 +528,45 @@ async function validateCourse(courseDir) {
|
|
|
259
528
|
}
|
|
260
529
|
}
|
|
261
530
|
}
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
531
|
+
const lessonIdCounts = /* @__PURE__ */ new Map();
|
|
532
|
+
for (const lesson of manifest.lessons) {
|
|
533
|
+
lessonIdCounts.set(lesson.id, (lessonIdCounts.get(lesson.id) ?? 0) + 1);
|
|
534
|
+
}
|
|
535
|
+
for (const [id, count] of lessonIdCounts) {
|
|
536
|
+
if (count > 1) {
|
|
537
|
+
issues.push({
|
|
538
|
+
path: "lessons",
|
|
539
|
+
message: `Duplicate lesson ID: ${id}`,
|
|
540
|
+
severity: "error"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
issues.push(...validateFlow(manifest));
|
|
545
|
+
if (manifest.variables) {
|
|
546
|
+
for (const [name, def] of Object.entries(manifest.variables)) {
|
|
547
|
+
const t = def.type;
|
|
548
|
+
if (t === "string" && typeof def.default !== "string") {
|
|
549
|
+
issues.push({
|
|
550
|
+
path: `variables.${name}.default`,
|
|
551
|
+
message: "Default must be a string when type is string",
|
|
552
|
+
severity: "error"
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (t === "number" && typeof def.default !== "number") {
|
|
556
|
+
issues.push({
|
|
557
|
+
path: `variables.${name}.default`,
|
|
558
|
+
message: "Default must be a number when type is number",
|
|
559
|
+
severity: "error"
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (t === "boolean" && typeof def.default !== "boolean") {
|
|
563
|
+
issues.push({
|
|
564
|
+
path: `variables.${name}.default`,
|
|
565
|
+
message: "Default must be a boolean when type is boolean",
|
|
566
|
+
severity: "error"
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
269
570
|
}
|
|
270
571
|
return {
|
|
271
572
|
valid: issues.filter((i) => i.severity === "error").length === 0,
|
|
@@ -273,19 +574,101 @@ async function validateCourse(courseDir) {
|
|
|
273
574
|
issues
|
|
274
575
|
};
|
|
275
576
|
}
|
|
577
|
+
|
|
578
|
+
// src/assessments.ts
|
|
579
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
580
|
+
import { parse as parseYaml2 } from "yaml";
|
|
581
|
+
function toLearnerAssessment(assessment) {
|
|
582
|
+
const answerKey = {};
|
|
583
|
+
const feedback = {};
|
|
584
|
+
const questions = assessment.questions.map((q) => {
|
|
585
|
+
const correct = q.choices.find((c) => c.correct === true);
|
|
586
|
+
if (correct) {
|
|
587
|
+
answerKey[q.id] = correct.id;
|
|
588
|
+
}
|
|
589
|
+
if (q.explanation) {
|
|
590
|
+
feedback[q.id] = q.explanation;
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
id: q.id,
|
|
594
|
+
prompt: q.prompt,
|
|
595
|
+
choices: q.choices.map((c) => ({ id: c.id, text: c.text }))
|
|
596
|
+
};
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
learner: {
|
|
600
|
+
id: assessment.id,
|
|
601
|
+
title: assessment.title,
|
|
602
|
+
passingScore: assessment.passingScore,
|
|
603
|
+
questions
|
|
604
|
+
},
|
|
605
|
+
answerKey,
|
|
606
|
+
config: {
|
|
607
|
+
maxAttempts: assessment.maxAttempts ?? 1,
|
|
608
|
+
shuffleChoices: assessment.shuffleChoices ?? false,
|
|
609
|
+
showFeedback: assessment.showFeedback ?? "never"
|
|
610
|
+
},
|
|
611
|
+
feedback
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
async function buildRuntimeAssessmentBundle(courseDir, manifest) {
|
|
615
|
+
const assessments = {};
|
|
616
|
+
const answerKeys = {};
|
|
617
|
+
const configs = {};
|
|
618
|
+
const feedback = {};
|
|
619
|
+
for (const ref of manifest.assessments ?? []) {
|
|
620
|
+
const resolved = resolveCoursePath(courseDir, ref.file);
|
|
621
|
+
if (!resolved.ok) {
|
|
622
|
+
throw new Error(resolved.message);
|
|
623
|
+
}
|
|
624
|
+
const content = await readFile2(resolved.path, "utf-8");
|
|
625
|
+
const raw = parseYaml2(content);
|
|
626
|
+
const parsed = assessmentSchema.safeParse(raw);
|
|
627
|
+
if (!parsed.success) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`Invalid assessment ${ref.file}: ${parsed.error.issues.map((i) => i.message).join("; ")}`
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
if (parsed.data.id !== ref.id) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`Assessment file id "${parsed.data.id}" does not match manifest ref "${ref.id}"`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const built = toLearnerAssessment(parsed.data);
|
|
638
|
+
assessments[ref.id] = built.learner;
|
|
639
|
+
answerKeys[ref.id] = built.answerKey;
|
|
640
|
+
configs[ref.id] = built.config;
|
|
641
|
+
feedback[ref.id] = built.feedback;
|
|
642
|
+
}
|
|
643
|
+
return { assessments, answerKeys, configs, feedback };
|
|
644
|
+
}
|
|
276
645
|
export {
|
|
646
|
+
BUILTIN_COMPONENT_IDS,
|
|
647
|
+
assertResolvedPathContained,
|
|
277
648
|
assessmentQuestionSchema,
|
|
278
649
|
assessmentRefSchema,
|
|
279
650
|
assessmentSchema,
|
|
651
|
+
buildRuntimeAssessmentBundle,
|
|
652
|
+
collectActivityIds,
|
|
653
|
+
componentLessonSchema,
|
|
654
|
+
conditionSchema,
|
|
280
655
|
courseManifestSchema,
|
|
656
|
+
detectFlowCycles,
|
|
657
|
+
flowRuleSchema,
|
|
281
658
|
formatErrorMessage,
|
|
282
659
|
formatIssuePath,
|
|
283
660
|
htmlLessonSchema,
|
|
661
|
+
isBuiltinComponentId,
|
|
662
|
+
isPathContained,
|
|
284
663
|
lessonSchema,
|
|
285
664
|
loadManifest,
|
|
286
665
|
markdownLessonSchema,
|
|
287
666
|
resolveCoursePath,
|
|
288
667
|
runtimeConfigSchema,
|
|
668
|
+
showFeedbackSchema,
|
|
669
|
+
toLearnerAssessment,
|
|
289
670
|
trackingSchema,
|
|
290
|
-
validateCourse
|
|
671
|
+
validateCourse,
|
|
672
|
+
validateFlow,
|
|
673
|
+
variableDefSchema
|
|
291
674
|
};
|