@intellectif/lk-core 0.8.2 → 0.10.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +5 -4
  3. package/dist/{activity-CPtJUBek.d.cts → activity-B0zbvu18.d.cts} +177 -2
  4. package/dist/{activity-CPtJUBek.d.ts → activity-B0zbvu18.d.ts} +177 -2
  5. package/dist/{chunk-FVLKIL6W.cjs → chunk-3FEAEO3D.cjs} +18 -13
  6. package/dist/chunk-3FEAEO3D.cjs.map +1 -0
  7. package/dist/chunk-7ODQRNR4.cjs +2009 -0
  8. package/dist/chunk-7ODQRNR4.cjs.map +1 -0
  9. package/dist/chunk-FIS5KBCE.js +2009 -0
  10. package/dist/chunk-FIS5KBCE.js.map +1 -0
  11. package/dist/{chunk-NHGXOOZ2.js → chunk-IXXMYJI7.js} +2 -2
  12. package/dist/{chunk-YJZY5TPY.cjs → chunk-VBM5H6P5.cjs} +8 -8
  13. package/dist/{chunk-YJZY5TPY.cjs.map → chunk-VBM5H6P5.cjs.map} +1 -1
  14. package/dist/{chunk-7ACNBDSF.cjs → chunk-VTTRXGDD.cjs} +4 -4
  15. package/dist/{chunk-7ACNBDSF.cjs.map → chunk-VTTRXGDD.cjs.map} +1 -1
  16. package/dist/{chunk-2M76F32Y.js → chunk-XGCTAQSS.js} +7 -2
  17. package/dist/chunk-XGCTAQSS.js.map +1 -0
  18. package/dist/{chunk-LNA33IK7.js → chunk-ZTY4UIUD.js} +2 -2
  19. package/dist/{index-BvrA8nIV.d.ts → index-DSfETm4b.d.ts} +321 -3
  20. package/dist/{index-gIN564mV.d.cts → index-u_rBLkuc.d.cts} +321 -3
  21. package/dist/index.cjs +156 -42
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +159 -8
  24. package/dist/index.d.ts +159 -8
  25. package/dist/index.js +137 -23
  26. package/dist/index.js.map +1 -1
  27. package/dist/schemas.cjs +25 -3
  28. package/dist/schemas.cjs.map +1 -1
  29. package/dist/schemas.d.cts +2 -2
  30. package/dist/schemas.d.ts +2 -2
  31. package/dist/schemas.js +24 -2
  32. package/dist/scoring.cjs +3 -3
  33. package/dist/scoring.d.cts +2 -2
  34. package/dist/scoring.d.ts +2 -2
  35. package/dist/scoring.js +2 -2
  36. package/dist/xapi.cjs +3 -3
  37. package/dist/xapi.d.cts +1 -1
  38. package/dist/xapi.d.ts +1 -1
  39. package/dist/xapi.js +2 -2
  40. package/package.json +1 -1
  41. package/dist/chunk-2M76F32Y.js.map +0 -1
  42. package/dist/chunk-FKH4YT5X.cjs +0 -748
  43. package/dist/chunk-FKH4YT5X.cjs.map +0 -1
  44. package/dist/chunk-FVLKIL6W.cjs.map +0 -1
  45. package/dist/chunk-HCOWSGEZ.js +0 -748
  46. package/dist/chunk-HCOWSGEZ.js.map +0 -1
  47. /package/dist/{chunk-NHGXOOZ2.js.map → chunk-IXXMYJI7.js.map} +0 -0
  48. /package/dist/{chunk-LNA33IK7.js.map → chunk-ZTY4UIUD.js.map} +0 -0
@@ -0,0 +1,2009 @@
1
+ // src/errors.ts
2
+ var ActivitySchemaError = class extends Error {
3
+ constructor(activityType, errors) {
4
+ super(`Invalid activity data for type "${activityType}"`);
5
+ this.activityType = activityType;
6
+ this.errors = errors;
7
+ this.name = "ActivitySchemaError";
8
+ }
9
+ activityType;
10
+ errors;
11
+ };
12
+ var UnknownActivityTypeError = class extends Error {
13
+ constructor(activityType) {
14
+ super(`Activity type "${activityType}" is not registered`);
15
+ this.activityType = activityType;
16
+ this.name = "UnknownActivityTypeError";
17
+ }
18
+ activityType;
19
+ };
20
+ var RedactedScoringError = class extends Error {
21
+ constructor(activityType) {
22
+ super(
23
+ `Activity data for "${activityType}" carries no answer key (it looks redacted), so it cannot be scored. Score against the full activity data server-side, or use evaluate() which returns { status: "unscorable" }.`
24
+ );
25
+ this.activityType = activityType;
26
+ this.name = "RedactedScoringError";
27
+ }
28
+ activityType;
29
+ };
30
+ var DeferredScoringError = class extends Error {
31
+ constructor(activityType) {
32
+ super(
33
+ `Activity type "${activityType}" is graded asynchronously and has no synchronous score. Use evaluate() \u2014 it returns { status: 'deferred' } for this type.`
34
+ );
35
+ this.activityType = activityType;
36
+ this.name = "DeferredScoringError";
37
+ }
38
+ activityType;
39
+ };
40
+
41
+ // src/schemas/feedback.ts
42
+ import { z } from "zod/v4";
43
+ var FeedbackSchema = z.looseObject({
44
+ correct: z.string().min(1).optional(),
45
+ incorrect: z.string().min(1).optional()
46
+ });
47
+
48
+ // src/schemas/media.ts
49
+ import { z as z2 } from "zod/v4";
50
+ var MediaUrlSchema = z2.union([
51
+ z2.url().refine((value) => /^(https?|data|blob):/i.test(value), {
52
+ error: "Absolute media URLs must use the https:, http:, data:, or blob: scheme."
53
+ }),
54
+ z2.string().regex(/^\/(?!\/)\S*$/, {
55
+ error: 'Relative media URLs must be root-relative (a single leading "/").'
56
+ })
57
+ ]);
58
+ var NativeControlHintSchema = z2.enum(["hide-download", "hide-rate"]);
59
+ var MediaPlaybackSchema = z2.strictObject({
60
+ /**
61
+ * `native` (the resolved default) renders today's `<audio controls>`
62
+ * unchanged. `minimal` — resolved automatically whenever any enforcement
63
+ * field is set — replaces the browser bar with the SDK transport:
64
+ * play/pause, elapsed/total, mute, volume, optional speed, optional
65
+ * scrubber, and a live plays-remaining status.
66
+ */
67
+ controls: z2.enum(["native", "minimal"]).optional(),
68
+ /**
69
+ * How many times the recording may be STARTED. A play is consumed when
70
+ * playback begins from anywhere other than where it last stopped, so
71
+ * pausing, resuming, and paging between the questions of one listening
72
+ * group are all free. Enforced in `practice` and `exam`; never in `review`.
73
+ */
74
+ maxPlays: z2.number().int().min(1).max(20).optional(),
75
+ /** `none` renders no scrubber and reverts an out-of-band seek to the high-water mark. */
76
+ seek: z2.enum(["allow", "none"]).optional(),
77
+ /** `fixed` renders no speed control and snaps `playbackRate` back to 1. */
78
+ rate: z2.enum(["allow", "fixed"]).optional(),
79
+ /** Advisory only. See {@link NativeControlHintSchema}. */
80
+ nativeControlHints: z2.array(NativeControlHintSchema).min(1).max(2).optional()
81
+ });
82
+ var MediaSchema = z2.looseObject({
83
+ type: z2.enum(["image", "audio", "video", "embed"]),
84
+ url: MediaUrlSchema,
85
+ alt: z2.string().min(1).optional(),
86
+ captionsUrl: MediaUrlSchema.optional(),
87
+ playback: MediaPlaybackSchema.optional()
88
+ }).refine(
89
+ (m) => m.type !== "image" && m.type !== "embed" || typeof m.alt === "string" && m.alt.length > 0,
90
+ {
91
+ error: "image and embed media require non-empty alt text (WCAG 1.1.1 / 4.1.2).",
92
+ path: ["alt"]
93
+ }
94
+ ).refine((m) => m.type !== "embed" || /^https?:\/\//i.test(m.url), {
95
+ error: "embed media requires an absolute http(s) provider URL. data:, blob:, and relative URLs are not allowed for embeds \u2014 the embed iframe runs with allow-scripts, and a data:/same-origin document there is an XSS vector.",
96
+ path: ["url"]
97
+ }).refine((m) => m.playback === void 0 || m.type === "audio", {
98
+ error: "playback policy is supported on audio media only. An embed is a provider iframe the SDK cannot control at all (it has no reliable JS API for a third-party player); an image has nothing to play; video is deliberately deferred, because a video transport must also own fullscreen and Picture-in-Picture and the SDK will not pretend to govern those yet.",
99
+ path: ["playback"]
100
+ }).refine(
101
+ (m) => m.playback?.controls !== "native" || m.playback.maxPlays === void 0 && m.playback.seek !== "none" && m.playback.rate !== "fixed",
102
+ {
103
+ error: 'controls: "native" cannot carry maxPlays, seek: "none" or rate: "fixed". The browser\'s own bar keeps its play button enabled after a budget is spent, and keeps a scrubber that would move and then silently snap back \u2014 a control that looks operable and does nothing (WCAG 3.2.2 / 4.1.3). Omit `controls` and the SDK transport is used automatically, or use nativeControlHints for a cosmetic hint.',
104
+ path: ["playback", "controls"]
105
+ }
106
+ ).refine((m) => m.playback?.maxPlays === void 0 || m.playback.seek !== "allow", {
107
+ error: 'maxPlays cannot be combined with seek: "allow". A play is consumed when playback starts from somewhere other than where it stopped, so scrubbing back mid-play would replay the whole recording without spending anything. Omit `seek` \u2014 it resolves to "none" under a budget.',
108
+ path: ["playback", "seek"]
109
+ }).refine(
110
+ (m) => m.playback?.controls !== "minimal" || m.playback.maxPlays !== void 0 || m.playback.seek === "none" || m.playback.rate === "fixed",
111
+ {
112
+ error: 'controls: "minimal" with nothing to enforce trades the browser\'s localized, familiar control bar for the SDK\'s, and buys nothing. Set maxPlays, seek: "none" or rate: "fixed", or omit `controls`.',
113
+ path: ["playback", "controls"]
114
+ }
115
+ ).refine(
116
+ (m) => m.playback?.nativeControlHints === void 0 || m.playback.controls !== "minimal" && m.playback.maxPlays === void 0 && m.playback.seek !== "none" && m.playback.rate !== "fixed",
117
+ {
118
+ error: "nativeControlHints only affects the browser's own control bar, and an enforcing policy replaces that bar with the SDK transport \u2014 so the hints would be silently inert. Use them on an otherwise unrestricted recording, or drop them.",
119
+ path: ["playback", "nativeControlHints"]
120
+ }
121
+ );
122
+ var RedactedMediaSchema = z2.strictObject({
123
+ type: z2.enum(["image", "audio", "video", "embed"]),
124
+ url: MediaUrlSchema,
125
+ alt: z2.string().min(1).optional(),
126
+ captionsUrl: MediaUrlSchema.optional(),
127
+ playback: MediaPlaybackSchema.optional()
128
+ }).refine(
129
+ (m) => m.type !== "image" && m.type !== "embed" || typeof m.alt === "string" && m.alt.length > 0,
130
+ {
131
+ error: "image and embed media require non-empty alt text (WCAG 1.1.1 / 4.1.2).",
132
+ path: ["alt"]
133
+ }
134
+ ).refine((m) => m.type !== "embed" || /^https?:\/\//i.test(m.url), {
135
+ error: "embed media requires an absolute http(s) provider URL. data:, blob:, and relative URLs are not allowed for embeds \u2014 the embed iframe runs with allow-scripts, and a data:/same-origin document there is an XSS vector.",
136
+ path: ["url"]
137
+ });
138
+
139
+ // src/schemas/fill-in-the-blanks.ts
140
+ import { z as z3 } from "zod/v4";
141
+ var PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
142
+ var TextMatchPolicySchema = z3.looseObject({
143
+ caseSensitive: z3.boolean().optional(),
144
+ trim: z3.boolean().optional(),
145
+ normalize: z3.enum(["none", "NFC", "NFKC"]).optional(),
146
+ foldDiacritics: z3.boolean().optional(),
147
+ collapseInnerWhitespace: z3.boolean().optional(),
148
+ ignorePunctuation: z3.boolean().optional(),
149
+ levenshtein: z3.number().int().min(0).optional(),
150
+ locale: z3.string().optional()
151
+ });
152
+ var BlankConfigSchema = z3.looseObject({
153
+ id: z3.string().min(1),
154
+ acceptedAnswers: z3.array(
155
+ z3.string().min(1).refine((answer) => answer.trim().length > 0, {
156
+ error: "Accepted answers must contain non-whitespace characters."
157
+ })
158
+ ).min(1),
159
+ caseSensitive: z3.boolean().optional(),
160
+ trimWhitespace: z3.boolean().optional(),
161
+ match: TextMatchPolicySchema.optional(),
162
+ hint: z3.string().optional(),
163
+ feedback: z3.string().optional()
164
+ });
165
+ var FillInTheBlanksDataSchema = z3.looseObject({
166
+ schemaVersion: z3.literal("1.0"),
167
+ type: z3.literal("fill-in-the-blanks"),
168
+ id: z3.string().min(1),
169
+ title: z3.string().min(1),
170
+ passage: z3.string().min(1),
171
+ passageHtml: z3.string().optional(),
172
+ blanks: z3.array(BlankConfigSchema).min(1),
173
+ scoringStrategy: z3.enum(["all-or-nothing", "partial"]),
174
+ media: MediaSchema.optional(),
175
+ feedback: FeedbackSchema.optional(),
176
+ passThreshold: z3.number().min(0).max(1).optional(),
177
+ locale: z3.string().optional(),
178
+ learningObjectives: z3.array(z3.string()).optional(),
179
+ difficultyLevel: z3.literal([1, 2, 3, 4, 5]).optional()
180
+ }).refine(
181
+ (data) => {
182
+ const placeholderCounts2 = /* @__PURE__ */ new Map();
183
+ for (const match of data.passage.matchAll(PLACEHOLDER_RE)) {
184
+ const id = match[1];
185
+ placeholderCounts2.set(id, (placeholderCounts2.get(id) ?? 0) + 1);
186
+ }
187
+ const blankIds = data.blanks.map((blank) => blank.id);
188
+ if (new Set(blankIds).size !== blankIds.length) {
189
+ return false;
190
+ }
191
+ if (placeholderCounts2.size !== blankIds.length) {
192
+ return false;
193
+ }
194
+ return blankIds.every((id) => placeholderCounts2.get(id) === 1);
195
+ },
196
+ {
197
+ error: "Each blank id must appear exactly once in blanks[] and have exactly one matching {{id}} placeholder in the passage, and vice versa.",
198
+ path: ["passage"]
199
+ }
200
+ );
201
+
202
+ // src/schemas/multiple-choice.ts
203
+ import { z as z4 } from "zod/v4";
204
+ var MultipleChoiceOptionMediaSchema = z4.looseObject({
205
+ // No `embed`: an iframe swallows the click that selects the option, so the
206
+ // learner could not choose it. No `video`: a native control bar inside the
207
+ // option's label eats the same click.
208
+ type: z4.enum(["image", "audio"]),
209
+ url: MediaUrlSchema,
210
+ alt: z4.string().min(1).optional(),
211
+ captionsUrl: MediaUrlSchema.optional()
212
+ }).refine((media) => media.type !== "image" || media.alt !== void 0 && media.alt !== "", {
213
+ error: "An image option requires a non-empty `alt`.",
214
+ path: ["alt"]
215
+ }).refine((media) => !("playback" in media), {
216
+ error: "An option carries no playback policy. maxPlays, seek and rate are enforced only on the media above the question.",
217
+ path: ["playback"]
218
+ });
219
+ var MultipleChoiceOptionSchema = z4.looseObject({
220
+ id: z4.string().min(1),
221
+ text: z4.string().min(1),
222
+ isCorrect: z4.boolean(),
223
+ feedback: z4.string().optional(),
224
+ media: MultipleChoiceOptionMediaSchema.optional()
225
+ });
226
+ var MultipleChoiceDataSchema = z4.looseObject({
227
+ schemaVersion: z4.literal("1.0"),
228
+ type: z4.literal("multiple-choice"),
229
+ id: z4.string().min(1),
230
+ title: z4.string().min(1),
231
+ question: z4.string().min(1),
232
+ questionHtml: z4.string().optional(),
233
+ mode: z4.enum(["single", "multi"]),
234
+ options: z4.array(MultipleChoiceOptionSchema).min(2).max(26),
235
+ scoringStrategy: z4.enum(["all-or-nothing", "partial"]),
236
+ media: MediaSchema.optional(),
237
+ feedback: FeedbackSchema.optional(),
238
+ passThreshold: z4.number().min(0).max(1).optional(),
239
+ shuffle: z4.boolean().optional(),
240
+ locale: z4.string().optional(),
241
+ learningObjectives: z4.array(z4.string()).optional(),
242
+ difficultyLevel: z4.literal([1, 2, 3, 4, 5]).optional()
243
+ }).refine((data) => data.options.some((option) => option.isCorrect), {
244
+ error: "At least one option must be marked correct.",
245
+ path: ["options"]
246
+ }).refine(
247
+ (data) => data.mode !== "single" || data.options.filter((option) => option.isCorrect).length === 1,
248
+ {
249
+ error: 'Single-select activities (mode: "single") must have exactly one correct option.',
250
+ path: ["options"]
251
+ }
252
+ ).refine((data) => new Set(data.options.map((option) => option.id)).size === data.options.length, {
253
+ error: "Option ids must be unique within the activity.",
254
+ path: ["options"]
255
+ });
256
+
257
+ // src/count-words.ts
258
+ function countWords(text) {
259
+ if (typeof text !== "string") {
260
+ return 0;
261
+ }
262
+ const trimmed = text.trim();
263
+ if (trimmed === "") {
264
+ return 0;
265
+ }
266
+ return trimmed.split(/\s+/).length;
267
+ }
268
+
269
+ // src/schemas/gap-select.ts
270
+ import { z as z5 } from "zod/v4";
271
+ var GapSelectChoiceSchema = z5.looseObject({
272
+ id: z5.string().min(1),
273
+ text: z5.string().min(1)
274
+ });
275
+ var GapSelectBankSchema = z5.looseObject({
276
+ id: z5.string().min(1),
277
+ choices: z5.array(GapSelectChoiceSchema).min(2)
278
+ });
279
+ var GapSelectGapSchema = z5.looseObject({
280
+ id: z5.string().min(1),
281
+ choices: z5.array(GapSelectChoiceSchema).min(2).optional(),
282
+ bankId: z5.string().min(1).optional(),
283
+ correctChoiceId: z5.string().min(1),
284
+ feedback: z5.string().optional()
285
+ });
286
+ function resolveChoices(gap, banks) {
287
+ if (gap.choices !== void 0) {
288
+ return gap.bankId === void 0 ? gap.choices : void 0;
289
+ }
290
+ if (gap.bankId === void 0) {
291
+ return void 0;
292
+ }
293
+ return banks.find((bank) => bank.id === gap.bankId)?.choices;
294
+ }
295
+ function placeholderCounts(passage) {
296
+ const counts = /* @__PURE__ */ new Map();
297
+ for (const match of passage.matchAll(PLACEHOLDER_RE)) {
298
+ const id = match[1];
299
+ counts.set(id, (counts.get(id) ?? 0) + 1);
300
+ }
301
+ return counts;
302
+ }
303
+ var GapSelectDataSchema = z5.looseObject({
304
+ schemaVersion: z5.literal("1.0"),
305
+ type: z5.literal("gap-select"),
306
+ id: z5.string().min(1),
307
+ title: z5.string().min(1),
308
+ passage: z5.string().min(1),
309
+ passageHtml: z5.string().optional(),
310
+ gaps: z5.array(GapSelectGapSchema).min(1),
311
+ banks: z5.array(GapSelectBankSchema).optional(),
312
+ scoringStrategy: z5.enum(["all-or-nothing", "partial"]),
313
+ presentation: z5.literal("dropdown").optional(),
314
+ shuffleChoices: z5.boolean().optional(),
315
+ media: MediaSchema.optional(),
316
+ feedback: FeedbackSchema.optional(),
317
+ passThreshold: z5.number().min(0).max(1).optional(),
318
+ locale: z5.string().optional(),
319
+ learningObjectives: z5.array(z5.string()).optional(),
320
+ difficultyLevel: z5.literal([1, 2, 3, 4, 5]).optional()
321
+ }).refine(
322
+ (data) => {
323
+ const ids = (data.banks ?? []).map((bank) => bank.id);
324
+ return new Set(ids).size === ids.length;
325
+ },
326
+ { error: "Word bank ids must be unique.", path: ["banks"] }
327
+ ).refine(
328
+ (data) => {
329
+ const ids = data.gaps.map((gap) => gap.id);
330
+ return new Set(ids).size === ids.length;
331
+ },
332
+ { error: "Gap ids must be unique.", path: ["gaps"] }
333
+ ).refine(
334
+ (data) => {
335
+ const counts = placeholderCounts(data.passage);
336
+ const ids = data.gaps.map((gap) => gap.id);
337
+ return counts.size === new Set(ids).size && ids.every((id) => counts.get(id) === 1) && [...counts.values()].every((count) => count === 1);
338
+ },
339
+ {
340
+ error: "Every gap must appear exactly once in the passage as {{gap_id}}, and every {{gap_id}} must have a gap.",
341
+ path: ["passage"]
342
+ }
343
+ ).refine(
344
+ (data) => data.gaps.every((gap) => gap.choices === void 0 !== (gap.bankId === void 0)),
345
+ {
346
+ error: "Each gap needs exactly one choice source: its own `choices`, or a `bankId`.",
347
+ path: ["gaps"]
348
+ }
349
+ ).refine(
350
+ (data) => {
351
+ const bankIds = new Set((data.banks ?? []).map((bank) => bank.id));
352
+ return data.gaps.every((gap) => gap.bankId === void 0 || bankIds.has(gap.bankId));
353
+ },
354
+ { error: "A gap references a `bankId` that no word bank defines.", path: ["gaps"] }
355
+ ).refine(
356
+ (data) => {
357
+ const banks = data.banks ?? [];
358
+ return data.gaps.every((gap) => {
359
+ const choices = resolveChoices(gap, banks);
360
+ if (choices === void 0) {
361
+ return true;
362
+ }
363
+ const ids = choices.map((choice) => choice.id);
364
+ return new Set(ids).size === ids.length && ids.includes(gap.correctChoiceId);
365
+ });
366
+ },
367
+ {
368
+ error: "Each gap needs unique choice ids and a `correctChoiceId` that is one of the choices it offers.",
369
+ path: ["gaps"]
370
+ }
371
+ );
372
+
373
+ // src/schemas/written-response.ts
374
+ import { z as z6 } from "zod/v4";
375
+ var WrittenResponseRubricCriterionSchema = z6.looseObject({
376
+ name: z6.string().min(1),
377
+ description: z6.string().optional(),
378
+ weight: z6.number().min(0)
379
+ });
380
+ var WrittenResponseRubricSchema = z6.looseObject({
381
+ label: z6.string().optional(),
382
+ criteria: z6.array(WrittenResponseRubricCriterionSchema).min(1)
383
+ });
384
+ var RedactedWrittenResponseRubricCriterionSchema = z6.strictObject({
385
+ name: z6.string().min(1),
386
+ description: z6.string().optional(),
387
+ weight: z6.number().min(0)
388
+ });
389
+ var RedactedWrittenResponseRubricSchema = z6.strictObject({
390
+ label: z6.string().optional(),
391
+ criteria: z6.array(RedactedWrittenResponseRubricCriterionSchema).min(1)
392
+ });
393
+ var WrittenResponseDataSchema = z6.looseObject({
394
+ schemaVersion: z6.literal("1.0"),
395
+ type: z6.literal("written-response"),
396
+ id: z6.string().min(1),
397
+ title: z6.string().min(1),
398
+ prompt: z6.string(),
399
+ promptHtml: z6.string().optional(),
400
+ minWords: z6.number().int().min(0),
401
+ maxWords: z6.number().int().min(1),
402
+ rubric: WrittenResponseRubricSchema.optional(),
403
+ languageTarget: z6.string().optional(),
404
+ media: MediaSchema.optional(),
405
+ feedback: FeedbackSchema.optional(),
406
+ passThreshold: z6.number().min(0).max(1).optional(),
407
+ locale: z6.string().optional(),
408
+ learningObjectives: z6.array(z6.string()).optional(),
409
+ difficultyLevel: z6.literal([1, 2, 3, 4, 5]).optional()
410
+ }).refine((data) => data.maxWords >= data.minWords, {
411
+ error: "maxWords must be greater than or equal to minWords.",
412
+ path: ["maxWords"]
413
+ });
414
+
415
+ // src/schemas/redacted.ts
416
+ import { z as z7 } from "zod/v4";
417
+ var redactedBase = {
418
+ /** Marker distinguishing a redacted projection from full activity data. */
419
+ redacted: z7.literal(true),
420
+ /** Slot identity, carried through redaction so the client and the plan agree. */
421
+ slotKey: z7.string().min(1).optional(),
422
+ schemaVersion: z7.literal("1.0"),
423
+ id: z7.string().min(1),
424
+ title: z7.string().min(1),
425
+ media: RedactedMediaSchema.optional(),
426
+ passThreshold: z7.number().min(0).max(1).optional(),
427
+ locale: z7.string().optional(),
428
+ learningObjectives: z7.array(z7.string()).optional(),
429
+ difficultyLevel: z7.literal([1, 2, 3, 4, 5]).optional()
430
+ };
431
+ var RedactedMultipleChoiceOptionMediaSchema = z7.strictObject({
432
+ type: z7.enum(["image", "audio"]),
433
+ url: z7.string().min(1),
434
+ alt: z7.string().min(1).optional(),
435
+ captionsUrl: z7.string().min(1).optional()
436
+ });
437
+ var RedactedMultipleChoiceOptionSchema = z7.strictObject({
438
+ id: z7.string().min(1),
439
+ text: z7.string().min(1),
440
+ media: RedactedMultipleChoiceOptionMediaSchema.optional()
441
+ });
442
+ var RedactedMultipleChoiceDataSchema = z7.strictObject({
443
+ ...redactedBase,
444
+ type: z7.literal("multiple-choice"),
445
+ question: z7.string().min(1),
446
+ questionHtml: z7.string().optional(),
447
+ mode: z7.enum(["single", "multi"]),
448
+ options: z7.array(RedactedMultipleChoiceOptionSchema).min(2).max(26),
449
+ shuffle: z7.boolean().optional()
450
+ });
451
+ var RedactedBlankConfigSchema = z7.strictObject({
452
+ id: z7.string().min(1),
453
+ hint: z7.string().optional()
454
+ });
455
+ var RedactedFillInTheBlanksDataSchema = z7.strictObject({
456
+ ...redactedBase,
457
+ type: z7.literal("fill-in-the-blanks"),
458
+ passage: z7.string().min(1),
459
+ passageHtml: z7.string().optional(),
460
+ blanks: z7.array(RedactedBlankConfigSchema).min(1)
461
+ });
462
+ var RedactedGapSelectChoiceSchema = z7.strictObject({
463
+ id: z7.string().min(1),
464
+ text: z7.string().min(1)
465
+ });
466
+ var RedactedGapSelectBankSchema = z7.strictObject({
467
+ id: z7.string().min(1),
468
+ choices: z7.array(RedactedGapSelectChoiceSchema).min(2)
469
+ });
470
+ var RedactedGapSelectGapSchema = z7.strictObject({
471
+ id: z7.string().min(1),
472
+ choices: z7.array(RedactedGapSelectChoiceSchema).min(2).optional(),
473
+ bankId: z7.string().min(1).optional()
474
+ });
475
+ var RedactedGapSelectDataSchema = z7.strictObject({
476
+ ...redactedBase,
477
+ type: z7.literal("gap-select"),
478
+ passage: z7.string().min(1),
479
+ passageHtml: z7.string().optional(),
480
+ gaps: z7.array(RedactedGapSelectGapSchema).min(1),
481
+ banks: z7.array(RedactedGapSelectBankSchema).optional(),
482
+ presentation: z7.literal("dropdown").optional(),
483
+ shuffleChoices: z7.boolean().optional()
484
+ });
485
+ var RedactedWrittenResponseDataSchema = z7.strictObject({
486
+ ...redactedBase,
487
+ type: z7.literal("written-response"),
488
+ prompt: z7.string(),
489
+ promptHtml: z7.string().optional(),
490
+ minWords: z7.number().int().min(0),
491
+ maxWords: z7.number().int().min(1),
492
+ rubric: RedactedWrittenResponseRubricSchema.optional(),
493
+ languageTarget: z7.string().optional()
494
+ });
495
+
496
+ // src/scoring/text-match.ts
497
+ var COMBINING_MARKS_RE = /\p{M}/gu;
498
+ var PUNCTUATION_RE = /\p{P}/gu;
499
+ function applyBaseline(value, policy) {
500
+ let result = value;
501
+ if (policy.trim !== false) {
502
+ result = result.trim();
503
+ }
504
+ if (policy.caseSensitive !== true) {
505
+ result = policy.locale ? result.toLocaleLowerCase(policy.locale) : result.toLowerCase();
506
+ }
507
+ return result;
508
+ }
509
+ function applyNormalization(value, policy) {
510
+ let result = value;
511
+ if (policy.normalize === "NFC" || policy.normalize === "NFKC") {
512
+ result = result.normalize(policy.normalize);
513
+ }
514
+ if (policy.ignorePunctuation === true) {
515
+ result = result.replace(PUNCTUATION_RE, "");
516
+ }
517
+ if (policy.collapseInnerWhitespace === true) {
518
+ result = result.replace(/(?<=\S)\s+(?=\S)/g, " ");
519
+ if (policy.trim !== false) {
520
+ result = result.trim();
521
+ }
522
+ }
523
+ return result;
524
+ }
525
+ function applyDiacriticFold(value) {
526
+ return value.normalize("NFD").replace(COMBINING_MARKS_RE, "").normalize("NFC");
527
+ }
528
+ function levenshteinDistance(a, b, max) {
529
+ if (a === b) return 0;
530
+ if (Math.abs(a.length - b.length) > max) return max + 1;
531
+ if (a.length === 0) return b.length;
532
+ if (b.length === 0) return a.length;
533
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
534
+ for (let i = 1; i <= a.length; i += 1) {
535
+ const current = [i];
536
+ let rowMin = i;
537
+ for (let j = 1; j <= b.length; j += 1) {
538
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
539
+ const value = Math.min(
540
+ previous[j] + 1,
541
+ current[j - 1] + 1,
542
+ previous[j - 1] + cost
543
+ );
544
+ current.push(value);
545
+ if (value < rowMin) rowMin = value;
546
+ }
547
+ if (rowMin > max) return max + 1;
548
+ previous = current;
549
+ }
550
+ return previous[b.length];
551
+ }
552
+ function matchText(input, accepted, policy = {}) {
553
+ const acceptedList = typeof accepted === "string" ? [accepted] : accepted;
554
+ const baselineInput = applyBaseline(input, policy);
555
+ const baselineAccepted = acceptedList.map((answer) => applyBaseline(answer, policy));
556
+ if (baselineAccepted.some((answer) => answer === baselineInput)) {
557
+ return { matched: true, via: "exact" };
558
+ }
559
+ const usesNormalization = policy.normalize === "NFC" || policy.normalize === "NFKC" || policy.ignorePunctuation === true || policy.collapseInnerWhitespace === true;
560
+ const normalizedInput = usesNormalization ? applyNormalization(baselineInput, policy) : baselineInput;
561
+ const normalizedAccepted = usesNormalization ? baselineAccepted.map((answer) => applyNormalization(answer, policy)) : baselineAccepted;
562
+ if (usesNormalization && normalizedAccepted.some((answer) => answer === normalizedInput)) {
563
+ return { matched: true, via: "normalized" };
564
+ }
565
+ const foldedInput = policy.foldDiacritics === true ? applyDiacriticFold(normalizedInput) : normalizedInput;
566
+ const foldedAccepted = policy.foldDiacritics === true ? normalizedAccepted.map((answer) => applyDiacriticFold(answer)) : normalizedAccepted;
567
+ if (policy.foldDiacritics === true && foldedAccepted.some((answer) => answer === foldedInput)) {
568
+ return { matched: true, via: "folded" };
569
+ }
570
+ const maxDistance = policy.levenshtein ?? 0;
571
+ if (maxDistance > 0 && foldedInput.trim().length > 0 && foldedAccepted.some(
572
+ (answer) => levenshteinDistance(foldedInput, answer, maxDistance) <= maxDistance
573
+ )) {
574
+ return { matched: true, via: "fuzzy" };
575
+ }
576
+ return { matched: false, via: "none" };
577
+ }
578
+
579
+ // src/registry/registry.ts
580
+ var registry = /* @__PURE__ */ new Map();
581
+ function defineActivityType(descriptor) {
582
+ return descriptor;
583
+ }
584
+ function registerActivityType(descriptor) {
585
+ if (descriptor.type === "item-group") {
586
+ throw new Error(
587
+ `"item-group" is reserved for the SDK's item-group container (see ItemGroup) and cannot be registered as an activity type.`
588
+ );
589
+ }
590
+ const existing = registry.get(descriptor.type);
591
+ if (existing !== void 0) {
592
+ if (existing === descriptor) {
593
+ return;
594
+ }
595
+ throw new Error(
596
+ `Activity type "${descriptor.type}" is already registered. Registering a different descriptor for an existing type is not allowed.`
597
+ );
598
+ }
599
+ registry.set(descriptor.type, descriptor);
600
+ }
601
+ function getActivityTypeDescriptor(type) {
602
+ return registry.get(type);
603
+ }
604
+ function registeredActivityTypes() {
605
+ return [...registry.keys()];
606
+ }
607
+
608
+ // src/authoring/issues.ts
609
+ var DRAFT_ISSUE_SEVERITY = {
610
+ // Every registered type — reported by validateDraft itself
611
+ null_not_allowed: "invalid",
612
+ // Every built-in activity
613
+ schema_version_invalid: "invalid",
614
+ type_mismatch: "invalid",
615
+ id_required: "invalid",
616
+ title_required: "incomplete",
617
+ scoring_strategy_required: "incomplete",
618
+ pass_threshold_invalid: "invalid",
619
+ difficulty_level_invalid: "invalid",
620
+ feedback_empty: "incomplete",
621
+ redacted_data: "invalid",
622
+ media_type_required: "incomplete",
623
+ media_url_required: "incomplete",
624
+ media_url_invalid: "invalid",
625
+ media_alt_required: "incomplete",
626
+ media_playback_invalid: "invalid",
627
+ media_invalid: "invalid",
628
+ // multiple-choice
629
+ mc_question_required: "incomplete",
630
+ mc_mode_required: "incomplete",
631
+ mc_options_too_few: "incomplete",
632
+ mc_options_too_many: "invalid",
633
+ mc_option_id_required: "invalid",
634
+ mc_option_id_duplicate: "invalid",
635
+ mc_option_text_required: "incomplete",
636
+ mc_option_correctness_required: "incomplete",
637
+ mc_option_media_kind: "invalid",
638
+ mc_correct_option_required: "incomplete",
639
+ mc_single_mode_one_correct: "invalid",
640
+ // fill-in-the-blanks
641
+ fib_passage_required: "incomplete",
642
+ fib_blanks_required: "incomplete",
643
+ fib_blank_id_required: "invalid",
644
+ fib_blank_id_duplicate: "invalid",
645
+ fib_accepted_answers_required: "incomplete",
646
+ fib_accepted_answer_empty: "incomplete",
647
+ fib_levenshtein_invalid: "invalid",
648
+ fib_match_locale_invalid: "invalid",
649
+ fib_match_invalid: "invalid",
650
+ fib_blank_missing: "incomplete",
651
+ fib_placeholder_missing: "incomplete",
652
+ fib_placeholder_duplicate: "invalid",
653
+ fib_blanks_mismatch: "invalid",
654
+ // gap-select
655
+ gs_passage_required: "incomplete",
656
+ gs_gaps_required: "incomplete",
657
+ gs_gap_id_required: "invalid",
658
+ gs_gap_id_duplicate: "invalid",
659
+ gs_gap_missing: "incomplete",
660
+ gs_placeholder_missing: "incomplete",
661
+ gs_placeholder_duplicate: "invalid",
662
+ gs_gaps_mismatch: "invalid",
663
+ gs_presentation_invalid: "invalid",
664
+ gs_bank_id_required: "invalid",
665
+ gs_bank_id_duplicate: "invalid",
666
+ gs_bank_unknown: "invalid",
667
+ gs_choice_source_required: "incomplete",
668
+ gs_choice_source_conflict: "invalid",
669
+ gs_choices_too_few: "incomplete",
670
+ gs_choice_id_required: "invalid",
671
+ gs_choice_id_duplicate: "invalid",
672
+ gs_choice_text_required: "incomplete",
673
+ gs_correct_choice_required: "incomplete",
674
+ gs_correct_choice_unknown: "invalid",
675
+ // written-response
676
+ wr_prompt_required: "incomplete",
677
+ wr_min_words_required: "incomplete",
678
+ wr_min_words_invalid: "invalid",
679
+ wr_max_words_required: "incomplete",
680
+ wr_max_words_invalid: "invalid",
681
+ wr_word_bounds_order: "invalid",
682
+ wr_rubric_criteria_required: "incomplete",
683
+ wr_criterion_name_required: "incomplete",
684
+ wr_criterion_weight_required: "incomplete",
685
+ wr_criterion_weight_invalid: "invalid",
686
+ wr_rubric_weights_zero: "incomplete",
687
+ wr_rubric_weights_too_large: "invalid"
688
+ };
689
+ function issue(code, path, message) {
690
+ return { path: path.map(String), message, code, severity: DRAFT_ISSUE_SEVERITY[code] };
691
+ }
692
+ function isRecord(value) {
693
+ return typeof value === "object" && value !== null && !Array.isArray(value);
694
+ }
695
+ function valueAt(root, path) {
696
+ let node = root;
697
+ for (const segment of path) {
698
+ if (typeof node !== "object" || node === null) {
699
+ return void 0;
700
+ }
701
+ node = node[segment];
702
+ }
703
+ return node;
704
+ }
705
+ function refusesEmpty(root, schemaIssue) {
706
+ const { path } = schemaIssue;
707
+ const value = valueAt(root, path);
708
+ const index = path.at(-1);
709
+ const list = valueAt(root, path.slice(0, -1));
710
+ const empty = value === null || value === void 0 && typeof index === "number" && Array.isArray(list) && index < list.length;
711
+ return empty && (schemaIssue.input === value || schemaIssue.code === "invalid_union");
712
+ }
713
+ function isUnset(value) {
714
+ return value === void 0 || value === null;
715
+ }
716
+ function isUnwritten(value) {
717
+ return isUnset(value) || typeof value === "string" && value.trim() === "";
718
+ }
719
+ function isMissingId(value) {
720
+ return isUnset(value) || value === "";
721
+ }
722
+ function isWholeNumber(value) {
723
+ return typeof value === "number" && Number.isSafeInteger(value);
724
+ }
725
+ function isTooLarge(value) {
726
+ return typeof value === "number" && value > Number.MAX_SAFE_INTEGER && Number.isInteger(value);
727
+ }
728
+ function checkIdentity(draft, type) {
729
+ const issues = [];
730
+ if (draft.schemaVersion !== "1.0") {
731
+ issues.push(
732
+ issue(
733
+ "schema_version_invalid",
734
+ ["schemaVersion"],
735
+ 'The draft must have schemaVersion "1.0". A draft from createDraft has it.'
736
+ )
737
+ );
738
+ }
739
+ if (draft.type !== type) {
740
+ issues.push(issue("type_mismatch", ["type"], `The draft's type must be "${type}".`));
741
+ }
742
+ if (isMissingId(draft.id)) {
743
+ issues.push(issue("id_required", ["id"], "The activity has no id."));
744
+ }
745
+ if (isUnwritten(draft.title)) {
746
+ issues.push(issue("title_required", ["title"], "Add a title."));
747
+ }
748
+ return issues;
749
+ }
750
+ function checkScoringStrategy(draft) {
751
+ return isUnwritten(draft.scoringStrategy) ? [
752
+ issue(
753
+ "scoring_strategy_required",
754
+ ["scoringStrategy"],
755
+ "Choose how the question is scored."
756
+ )
757
+ ] : [];
758
+ }
759
+ var DIFFICULTY_LEVELS = [1, 2, 3, 4, 5];
760
+ function checkSharedOptional(draft) {
761
+ const issues = [];
762
+ const threshold = draft.passThreshold;
763
+ if (!isUnset(threshold) && !(typeof threshold === "number" && threshold >= 0 && threshold <= 1)) {
764
+ issues.push(
765
+ issue(
766
+ "pass_threshold_invalid",
767
+ ["passThreshold"],
768
+ "The pass threshold must be a number from 0 to 1."
769
+ )
770
+ );
771
+ }
772
+ if (!isUnset(draft.difficultyLevel) && !DIFFICULTY_LEVELS.includes(draft.difficultyLevel)) {
773
+ issues.push(
774
+ issue(
775
+ "difficulty_level_invalid",
776
+ ["difficultyLevel"],
777
+ "The difficulty level must be a whole number from 1 to 5."
778
+ )
779
+ );
780
+ }
781
+ const feedback = draft.feedback;
782
+ if (isRecord(feedback)) {
783
+ for (const key of ["correct", "incorrect"]) {
784
+ const message = feedback[key];
785
+ if (typeof message === "string" && message.trim() === "") {
786
+ issues.push(
787
+ issue("feedback_empty", ["feedback", key], "Write this feedback message, or remove it.")
788
+ );
789
+ }
790
+ }
791
+ }
792
+ if (draft.redacted === true) {
793
+ issues.push(
794
+ issue(
795
+ "redacted_data",
796
+ ["redacted"],
797
+ "This is a copy prepared for learners, with the answer key removed. Edit the original activity instead."
798
+ )
799
+ );
800
+ }
801
+ if (isRecord(draft.media)) {
802
+ issues.push(...checkMedia(draft.media));
803
+ }
804
+ return issues;
805
+ }
806
+ var URL_POLICY = 'Use an https:, http:, data: or blob: address, or a path on the same site that starts with a single "/".';
807
+ var EMBED_URL = "An embed needs the provider's full http(s) embed address, such as https://www.youtube.com/embed/VIDEO_ID.";
808
+ function checkMedia(media, prefix = ["media"], schema = MediaSchema) {
809
+ const issues = [];
810
+ const at = (...rest) => [...prefix, ...rest];
811
+ const parsed = schema.safeParse(media, { reportInput: true });
812
+ const schemaIssues = parsed.success ? [] : parsed.error.issues.filter((found) => !refusesEmpty(media, found));
813
+ const refusal = (field) => schemaIssues.find((found) => found.path[0] === field);
814
+ const typeUnset = isUnwritten(media.type);
815
+ if (typeUnset) {
816
+ issues.push(issue("media_type_required", at("type"), "Choose what kind of media this is."));
817
+ }
818
+ if (isUnwritten(media.url)) {
819
+ issues.push(issue("media_url_required", at("url"), "Add the address of the media file."));
820
+ } else {
821
+ const refused = refusal("url");
822
+ if (refused !== void 0) {
823
+ issues.push(
824
+ issue("media_url_invalid", at("url"), refused.code === "custom" ? EMBED_URL : URL_POLICY)
825
+ );
826
+ }
827
+ }
828
+ const needsAlt = media.type === "image" || media.type === "embed";
829
+ const alt = media.alt;
830
+ if (needsAlt && isUnwritten(alt) || typeof alt === "string" && alt.trim() === "") {
831
+ issues.push(issue("media_alt_required", at("alt"), "Add a text description of the media."));
832
+ }
833
+ const captions = media.captionsUrl;
834
+ if (typeof captions === "string" && captions.trim() === "") {
835
+ issues.push(
836
+ issue(
837
+ "media_url_required",
838
+ at("captionsUrl"),
839
+ "Add the address of the captions file, or remove the captions."
840
+ )
841
+ );
842
+ } else if (refusal("captionsUrl") !== void 0) {
843
+ issues.push(issue("media_url_invalid", at("captionsUrl"), URL_POLICY));
844
+ }
845
+ for (const schemaIssue of schemaIssues) {
846
+ const field = schemaIssue.path[0];
847
+ if (field === "url" || field === "alt" || field === "captionsUrl" || field === "type" && typeUnset) {
848
+ continue;
849
+ }
850
+ issues.push(
851
+ issue(
852
+ field === "playback" ? "media_playback_invalid" : "media_invalid",
853
+ at(...schemaIssue.path.map(String)),
854
+ schemaIssue.message
855
+ )
856
+ );
857
+ }
858
+ return issues;
859
+ }
860
+
861
+ // src/authoring/fill-in-the-blanks.ts
862
+ var fillInTheBlanksAuthoring = {
863
+ createDraft: ({ newId }) => ({
864
+ schemaVersion: "1.0",
865
+ type: "fill-in-the-blanks",
866
+ id: newId(),
867
+ title: "",
868
+ passage: "",
869
+ blanks: [],
870
+ scoringStrategy: "all-or-nothing"
871
+ }),
872
+ checkDraft: checkFillInTheBlanksDraft
873
+ };
874
+ function checkFillInTheBlanksDraft(draft) {
875
+ const issues = checkIdentity(draft, "fill-in-the-blanks");
876
+ const passage = draft.passage;
877
+ if (isUnwritten(passage)) {
878
+ issues.push(issue("fib_passage_required", ["passage"], "Write the passage."));
879
+ }
880
+ issues.push(...checkScoringStrategy(draft));
881
+ const blanks = isUnset(draft.blanks) ? [] : draft.blanks;
882
+ if (Array.isArray(blanks)) {
883
+ if (blanks.length === 0) {
884
+ issues.push(
885
+ issue(
886
+ "fib_blanks_required",
887
+ ["blanks"],
888
+ "Add at least one blank, and mark where it goes in the passage with {{id}}."
889
+ )
890
+ );
891
+ }
892
+ const ids = [];
893
+ for (const [index, blank] of blanks.entries()) {
894
+ if (!isRecord(blank)) {
895
+ continue;
896
+ }
897
+ const name = typeof blank.id === "string" && blank.id !== "" ? `"${blank.id}"` : index + 1;
898
+ if (isMissingId(blank.id)) {
899
+ issues.push(
900
+ issue("fib_blank_id_required", ["blanks", index, "id"], `Blank ${index + 1} has no id.`)
901
+ );
902
+ } else if (typeof blank.id === "string") {
903
+ ids.push(blank.id);
904
+ }
905
+ const answers = isUnset(blank.acceptedAnswers) ? [] : blank.acceptedAnswers;
906
+ if (Array.isArray(answers)) {
907
+ if (answers.length === 0) {
908
+ issues.push(
909
+ issue(
910
+ "fib_accepted_answers_required",
911
+ ["blanks", index, "acceptedAnswers"],
912
+ `Add an accepted answer for blank ${name}.`
913
+ )
914
+ );
915
+ }
916
+ for (const [position, answer] of answers.entries()) {
917
+ if (typeof answer === "string" && answer.trim() === "") {
918
+ issues.push(
919
+ issue(
920
+ "fib_accepted_answer_empty",
921
+ ["blanks", index, "acceptedAnswers", position],
922
+ `An accepted answer for blank ${name} is empty. Fill it in or remove it.`
923
+ )
924
+ );
925
+ }
926
+ }
927
+ }
928
+ if (isRecord(blank.match)) {
929
+ issues.push(...checkMatch(blank.match, index));
930
+ }
931
+ }
932
+ if (typeof passage === "string" && passage.trim() !== "") {
933
+ issues.push(...checkPairing(passage, blanks, ids));
934
+ }
935
+ }
936
+ issues.push(...checkSharedOptional(draft));
937
+ return issues;
938
+ }
939
+ function checkMatch(match, index) {
940
+ const issues = [];
941
+ const at = (...rest) => ["blanks", index, "match", ...rest];
942
+ const tolerance = match.levenshtein;
943
+ if (!isUnset(tolerance) && !(isWholeNumber(tolerance) && tolerance >= 0)) {
944
+ issues.push(
945
+ issue(
946
+ "fib_levenshtein_invalid",
947
+ at("levenshtein"),
948
+ isTooLarge(tolerance) ? "Typo tolerance is too large." : "Typo tolerance must be a whole number, 0 or more."
949
+ )
950
+ );
951
+ }
952
+ const locale = match.locale;
953
+ if (typeof locale === "string" && locale !== "" && !isLanguageTag(locale)) {
954
+ issues.push(
955
+ issue(
956
+ "fib_match_locale_invalid",
957
+ at("locale"),
958
+ `"${locale}" is not a language tag. Use one such as "tr" or "en-US", or leave the locale out.`
959
+ )
960
+ );
961
+ }
962
+ const parsed = TextMatchPolicySchema.safeParse(match, { reportInput: true });
963
+ if (!parsed.success) {
964
+ for (const schemaIssue of parsed.error.issues) {
965
+ if (schemaIssue.path[0] === "levenshtein" || refusesEmpty(match, schemaIssue)) {
966
+ continue;
967
+ }
968
+ issues.push(
969
+ issue("fib_match_invalid", at(...schemaIssue.path.map(String)), schemaIssue.message)
970
+ );
971
+ }
972
+ }
973
+ return issues;
974
+ }
975
+ function isLanguageTag(tag) {
976
+ try {
977
+ Intl.getCanonicalLocales(tag);
978
+ return true;
979
+ } catch {
980
+ return false;
981
+ }
982
+ }
983
+ function checkPairing(passage, blanks, ids) {
984
+ const issues = [];
985
+ const placeholders = /* @__PURE__ */ new Map();
986
+ for (const match of passage.matchAll(PLACEHOLDER_RE)) {
987
+ const id = match[1];
988
+ placeholders.set(id, (placeholders.get(id) ?? 0) + 1);
989
+ }
990
+ const blankIds = /* @__PURE__ */ new Map();
991
+ for (const id of ids) {
992
+ blankIds.set(id, (blankIds.get(id) ?? 0) + 1);
993
+ }
994
+ for (const [id, count] of blankIds) {
995
+ if (count > 1) {
996
+ issues.push(
997
+ issue("fib_blank_id_duplicate", ["passage"], `More than one blank has the id "${id}".`)
998
+ );
999
+ }
1000
+ }
1001
+ for (const [id, count] of placeholders) {
1002
+ if (count > 1) {
1003
+ issues.push(
1004
+ issue(
1005
+ "fib_placeholder_duplicate",
1006
+ ["passage"],
1007
+ `{{${id}}} appears more than once in the passage. Each blank goes in one place.`
1008
+ )
1009
+ );
1010
+ }
1011
+ if (!blankIds.has(id)) {
1012
+ issues.push(
1013
+ issue(
1014
+ "fib_blank_missing",
1015
+ ["passage"],
1016
+ `The passage has {{${id}}}, but there is no blank with that id.`
1017
+ )
1018
+ );
1019
+ }
1020
+ }
1021
+ for (const id of blankIds.keys()) {
1022
+ if (!placeholders.has(id)) {
1023
+ issues.push(
1024
+ issue(
1025
+ "fib_placeholder_missing",
1026
+ ["passage"],
1027
+ `Blank "${id}" is not in the passage. Put {{${id}}} where it goes.`
1028
+ )
1029
+ );
1030
+ }
1031
+ }
1032
+ if (issues.length === 0 && !pairsOneToOne(placeholders, blanks)) {
1033
+ issues.push(
1034
+ issue(
1035
+ "fib_blanks_mismatch",
1036
+ ["passage"],
1037
+ "The blanks and the {{id}} placeholders in the passage do not pair up one to one."
1038
+ )
1039
+ );
1040
+ }
1041
+ return issues;
1042
+ }
1043
+ function pairsOneToOne(placeholders, blanks) {
1044
+ const blankIds = blanks.filter(isRecord).map((blank) => blank.id);
1045
+ if (new Set(blankIds).size !== blankIds.length) {
1046
+ return false;
1047
+ }
1048
+ if (placeholders.size !== blankIds.length) {
1049
+ return false;
1050
+ }
1051
+ return blankIds.every((id) => typeof id === "string" && placeholders.get(id) === 1);
1052
+ }
1053
+
1054
+ // src/authoring/gap-select.ts
1055
+ var MIN_CHOICES = 2;
1056
+ var gapSelectAuthoring = {
1057
+ createDraft: ({ newId }) => ({
1058
+ schemaVersion: "1.0",
1059
+ type: "gap-select",
1060
+ id: newId(),
1061
+ title: "",
1062
+ passage: "",
1063
+ gaps: [],
1064
+ scoringStrategy: "all-or-nothing"
1065
+ }),
1066
+ checkDraft: checkGapSelectDraft
1067
+ };
1068
+ function checkGapSelectDraft(draft) {
1069
+ const issues = checkIdentity(draft, "gap-select");
1070
+ const passage = draft.passage;
1071
+ if (isUnwritten(passage)) {
1072
+ issues.push(issue("gs_passage_required", ["passage"], "Write the passage."));
1073
+ }
1074
+ issues.push(...checkScoringStrategy(draft));
1075
+ if (!isUnset(draft.presentation) && draft.presentation !== "dropdown") {
1076
+ issues.push(
1077
+ issue(
1078
+ "gs_presentation_invalid",
1079
+ ["presentation"],
1080
+ 'The only presentation is "dropdown". Leave it out unless you mean to set it.'
1081
+ )
1082
+ );
1083
+ }
1084
+ const banks = isUnset(draft.banks) ? [] : draft.banks;
1085
+ const bankIds = /* @__PURE__ */ new Set();
1086
+ if (Array.isArray(banks)) {
1087
+ issues.push(...checkBanks(banks, bankIds));
1088
+ }
1089
+ const gaps = isUnset(draft.gaps) ? [] : draft.gaps;
1090
+ const gapIds = [];
1091
+ if (Array.isArray(gaps)) {
1092
+ if (gaps.length === 0) {
1093
+ issues.push(
1094
+ issue(
1095
+ "gs_gaps_required",
1096
+ ["gaps"],
1097
+ "Add at least one gap, and mark where it goes in the passage with {{id}}."
1098
+ )
1099
+ );
1100
+ }
1101
+ issues.push(...checkGaps(gaps, banks, bankIds, gapIds));
1102
+ if (typeof passage === "string" && passage.trim() !== "") {
1103
+ issues.push(...checkPairing2(passage, gaps, gapIds));
1104
+ }
1105
+ }
1106
+ issues.push(...checkSharedOptional(draft));
1107
+ return issues;
1108
+ }
1109
+ function checkBanks(banks, bankIds) {
1110
+ const issues = [];
1111
+ const repeated = /* @__PURE__ */ new Set();
1112
+ for (const [index, bank] of banks.entries()) {
1113
+ if (!isRecord(bank)) {
1114
+ continue;
1115
+ }
1116
+ if (isMissingId(bank.id)) {
1117
+ issues.push(
1118
+ issue("gs_bank_id_required", ["banks", index, "id"], `Word bank ${index + 1} has no id.`)
1119
+ );
1120
+ } else if (typeof bank.id === "string") {
1121
+ if (bankIds.has(bank.id)) {
1122
+ repeated.add(bank.id);
1123
+ }
1124
+ bankIds.add(bank.id);
1125
+ }
1126
+ const name = typeof bank.id === "string" && bank.id !== "" ? `"${bank.id}"` : index + 1;
1127
+ issues.push(...checkChoiceList(bank.choices, ["banks", index, "choices"], `word bank ${name}`));
1128
+ }
1129
+ for (const id of repeated) {
1130
+ issues.push(
1131
+ issue("gs_bank_id_duplicate", ["banks"], `More than one word bank has the id "${id}".`)
1132
+ );
1133
+ }
1134
+ return issues;
1135
+ }
1136
+ function checkGaps(gaps, banks, bankIds, gapIds) {
1137
+ const issues = [];
1138
+ const atGaps = [];
1139
+ const repeated = /* @__PURE__ */ new Set();
1140
+ const seen = /* @__PURE__ */ new Set();
1141
+ for (const [index, gap] of gaps.entries()) {
1142
+ if (!isRecord(gap)) {
1143
+ continue;
1144
+ }
1145
+ const ordinal = index + 1;
1146
+ const name = typeof gap.id === "string" && gap.id !== "" ? `"${gap.id}"` : ordinal;
1147
+ if (isMissingId(gap.id)) {
1148
+ issues.push(issue("gs_gap_id_required", ["gaps", index, "id"], `Gap ${ordinal} has no id.`));
1149
+ } else if (typeof gap.id === "string") {
1150
+ if (seen.has(gap.id)) {
1151
+ repeated.add(gap.id);
1152
+ }
1153
+ seen.add(gap.id);
1154
+ gapIds.push(gap.id);
1155
+ }
1156
+ const hasOwn = !isUnset(gap.choices);
1157
+ const hasBank = !isUnwritten(gap.bankId);
1158
+ const bankBlank = typeof gap.bankId === "string" && gap.bankId.trim() === "";
1159
+ if (bankBlank) {
1160
+ issues.push(
1161
+ issue(
1162
+ "gs_choice_source_required",
1163
+ ["gaps", index, "bankId"],
1164
+ `Choose a word bank for gap ${name}, or remove the empty field.`
1165
+ )
1166
+ );
1167
+ } else if (hasOwn && hasBank) {
1168
+ atGaps.push(
1169
+ issue(
1170
+ "gs_choice_source_conflict",
1171
+ ["gaps"],
1172
+ `Gap ${name} has both its own choices and a word bank. Use one or the other.`
1173
+ )
1174
+ );
1175
+ } else if (!hasOwn && !hasBank) {
1176
+ atGaps.push(
1177
+ issue(
1178
+ "gs_choice_source_required",
1179
+ ["gaps"],
1180
+ `Give gap ${name} a list of choices, or point it at a word bank.`
1181
+ )
1182
+ );
1183
+ }
1184
+ if (hasBank && typeof gap.bankId === "string" && !bankIds.has(gap.bankId)) {
1185
+ atGaps.push(
1186
+ issue(
1187
+ "gs_bank_unknown",
1188
+ ["gaps"],
1189
+ `Gap ${name} uses the word bank "${gap.bankId}", which does not exist.`
1190
+ )
1191
+ );
1192
+ }
1193
+ if (hasOwn) {
1194
+ issues.push(...checkChoiceList(gap.choices, ["gaps", index, "choices"], `gap ${name}`));
1195
+ }
1196
+ issues.push(...checkAnswerKey(gap, index, name, banks, bankIds));
1197
+ }
1198
+ for (const id of repeated) {
1199
+ atGaps.push(issue("gs_gap_id_duplicate", ["gaps"], `More than one gap has the id "${id}".`));
1200
+ }
1201
+ return [...issues, ...atGaps];
1202
+ }
1203
+ function checkAnswerKey(gap, index, name, banks, bankIds) {
1204
+ const key = gap.correctChoiceId;
1205
+ if (isUnwritten(key)) {
1206
+ return [
1207
+ issue(
1208
+ "gs_correct_choice_required",
1209
+ ["gaps", index, "correctChoiceId"],
1210
+ `Mark the correct choice for gap ${name}.`
1211
+ )
1212
+ ];
1213
+ }
1214
+ if (typeof key !== "string") {
1215
+ return [];
1216
+ }
1217
+ const choices = resolveChoices2(gap, banks, bankIds);
1218
+ if (choices === void 0) {
1219
+ return [];
1220
+ }
1221
+ const offered = choices.some((choice) => isRecord(choice) && choice.id === key);
1222
+ return offered ? [] : [
1223
+ issue(
1224
+ "gs_correct_choice_unknown",
1225
+ ["gaps"],
1226
+ `Gap ${name} is marked correct on "${key}", which is not one of its choices.`
1227
+ )
1228
+ ];
1229
+ }
1230
+ function resolveChoices2(gap, banks, bankIds) {
1231
+ const hasOwn = !isUnset(gap.choices);
1232
+ const hasBank = !isUnwritten(gap.bankId);
1233
+ if (hasOwn === hasBank) {
1234
+ return void 0;
1235
+ }
1236
+ if (hasOwn) {
1237
+ return Array.isArray(gap.choices) ? gap.choices : void 0;
1238
+ }
1239
+ if (typeof gap.bankId !== "string" || !bankIds.has(gap.bankId) || !Array.isArray(banks)) {
1240
+ return void 0;
1241
+ }
1242
+ const bank = banks.find((entry) => isRecord(entry) && entry.id === gap.bankId);
1243
+ return isRecord(bank) && Array.isArray(bank.choices) ? bank.choices : void 0;
1244
+ }
1245
+ function checkChoiceList(choices, path, owner) {
1246
+ const issues = [];
1247
+ const list = isUnset(choices) ? [] : choices;
1248
+ if (!Array.isArray(list)) {
1249
+ return issues;
1250
+ }
1251
+ if (list.length < MIN_CHOICES) {
1252
+ issues.push(
1253
+ issue(
1254
+ "gs_choices_too_few",
1255
+ path,
1256
+ `Give ${owner} at least ${MIN_CHOICES} choices to pick from.`
1257
+ )
1258
+ );
1259
+ }
1260
+ const seen = /* @__PURE__ */ new Set();
1261
+ const repeated = /* @__PURE__ */ new Set();
1262
+ for (const [index, choice] of list.entries()) {
1263
+ if (!isRecord(choice)) {
1264
+ continue;
1265
+ }
1266
+ if (isMissingId(choice.id)) {
1267
+ issues.push(
1268
+ issue("gs_choice_id_required", [...path, index, "id"], `Choice ${index + 1} has no id.`)
1269
+ );
1270
+ } else if (typeof choice.id === "string") {
1271
+ if (seen.has(choice.id)) {
1272
+ repeated.add(choice.id);
1273
+ }
1274
+ seen.add(choice.id);
1275
+ }
1276
+ if (isUnwritten(choice.text)) {
1277
+ issues.push(
1278
+ issue(
1279
+ "gs_choice_text_required",
1280
+ [...path, index, "text"],
1281
+ `Write the text of choice ${index + 1}.`
1282
+ )
1283
+ );
1284
+ }
1285
+ }
1286
+ for (const id of repeated) {
1287
+ issues.push(issue("gs_choice_id_duplicate", path, `More than one choice has the id "${id}".`));
1288
+ }
1289
+ return issues;
1290
+ }
1291
+ function checkPairing2(passage, gaps, gapIds) {
1292
+ const issues = [];
1293
+ const placeholders = /* @__PURE__ */ new Map();
1294
+ for (const match of passage.matchAll(PLACEHOLDER_RE)) {
1295
+ const id = match[1];
1296
+ placeholders.set(id, (placeholders.get(id) ?? 0) + 1);
1297
+ }
1298
+ const ids = new Set(gapIds);
1299
+ for (const [id, count] of placeholders) {
1300
+ if (count > 1) {
1301
+ issues.push(
1302
+ issue(
1303
+ "gs_placeholder_duplicate",
1304
+ ["passage"],
1305
+ `{{${id}}} appears more than once in the passage. Each gap goes in one place.`
1306
+ )
1307
+ );
1308
+ }
1309
+ if (!ids.has(id)) {
1310
+ issues.push(
1311
+ issue(
1312
+ "gs_gap_missing",
1313
+ ["passage"],
1314
+ `The passage has {{${id}}}, but there is no gap with that id.`
1315
+ )
1316
+ );
1317
+ }
1318
+ }
1319
+ for (const id of ids) {
1320
+ if (!placeholders.has(id)) {
1321
+ issues.push(
1322
+ issue(
1323
+ "gs_placeholder_missing",
1324
+ ["passage"],
1325
+ `Gap "${id}" is not in the passage. Put {{${id}}} where it goes.`
1326
+ )
1327
+ );
1328
+ }
1329
+ }
1330
+ if (issues.length === 0 && !pairsOneToOne2(placeholders, gaps)) {
1331
+ issues.push(
1332
+ issue(
1333
+ "gs_gaps_mismatch",
1334
+ ["passage"],
1335
+ "The gaps and the {{id}} placeholders in the passage do not pair up one to one."
1336
+ )
1337
+ );
1338
+ }
1339
+ return issues;
1340
+ }
1341
+ function pairsOneToOne2(placeholders, gaps) {
1342
+ const ids = gaps.filter(isRecord).map((gap) => gap.id);
1343
+ if (new Set(ids).size !== ids.length || placeholders.size !== ids.length) {
1344
+ return false;
1345
+ }
1346
+ return ids.every((id) => typeof id === "string" && placeholders.get(id) === 1);
1347
+ }
1348
+
1349
+ // src/authoring/multiple-choice.ts
1350
+ var MIN_OPTIONS = 2;
1351
+ var MAX_OPTIONS = 26;
1352
+ var multipleChoiceAuthoring = {
1353
+ createDraft: ({ newId }) => ({
1354
+ schemaVersion: "1.0",
1355
+ type: "multiple-choice",
1356
+ id: newId(),
1357
+ title: "",
1358
+ question: "",
1359
+ mode: "single",
1360
+ scoringStrategy: "all-or-nothing",
1361
+ options: [
1362
+ { id: newId(), text: "", isCorrect: false },
1363
+ { id: newId(), text: "", isCorrect: false }
1364
+ ]
1365
+ }),
1366
+ checkDraft: checkMultipleChoiceDraft
1367
+ };
1368
+ function checkMultipleChoiceDraft(draft) {
1369
+ const issues = checkIdentity(draft, "multiple-choice");
1370
+ if (isUnwritten(draft.question)) {
1371
+ issues.push(issue("mc_question_required", ["question"], "Write the question."));
1372
+ }
1373
+ if (isUnwritten(draft.mode)) {
1374
+ issues.push(
1375
+ issue("mc_mode_required", ["mode"], "Choose whether learners select one option or several.")
1376
+ );
1377
+ }
1378
+ issues.push(...checkScoringStrategy(draft));
1379
+ const options = isUnset(draft.options) ? [] : draft.options;
1380
+ if (Array.isArray(options)) {
1381
+ if (options.length < MIN_OPTIONS) {
1382
+ issues.push(issue("mc_options_too_few", ["options"], "Add at least two options."));
1383
+ }
1384
+ if (options.length > MAX_OPTIONS) {
1385
+ issues.push(
1386
+ issue("mc_options_too_many", ["options"], `Use no more than ${MAX_OPTIONS} options.`)
1387
+ );
1388
+ }
1389
+ const seen = /* @__PURE__ */ new Set();
1390
+ const repeated = /* @__PURE__ */ new Set();
1391
+ let correct = 0;
1392
+ for (const [index, option] of options.entries()) {
1393
+ if (!isRecord(option)) {
1394
+ continue;
1395
+ }
1396
+ const ordinal = index + 1;
1397
+ if (isMissingId(option.id)) {
1398
+ issues.push(
1399
+ issue("mc_option_id_required", ["options", index, "id"], `Option ${ordinal} has no id.`)
1400
+ );
1401
+ } else if (typeof option.id === "string") {
1402
+ if (seen.has(option.id)) {
1403
+ repeated.add(option.id);
1404
+ }
1405
+ seen.add(option.id);
1406
+ }
1407
+ if (isUnwritten(option.text)) {
1408
+ issues.push(
1409
+ issue(
1410
+ "mc_option_text_required",
1411
+ ["options", index, "text"],
1412
+ `Write the text of option ${ordinal}.`
1413
+ )
1414
+ );
1415
+ }
1416
+ if (isUnset(option.isCorrect)) {
1417
+ issues.push(
1418
+ issue(
1419
+ "mc_option_correctness_required",
1420
+ ["options", index, "isCorrect"],
1421
+ `Say whether option ${ordinal} is correct.`
1422
+ )
1423
+ );
1424
+ }
1425
+ if (option.isCorrect === true) {
1426
+ correct += 1;
1427
+ }
1428
+ if (isRecord(option.media)) {
1429
+ issues.push(...checkOptionMedia(option.media, index, ordinal));
1430
+ }
1431
+ }
1432
+ for (const id of repeated) {
1433
+ issues.push(
1434
+ issue("mc_option_id_duplicate", ["options"], `More than one option has the id "${id}".`)
1435
+ );
1436
+ }
1437
+ if (correct === 0) {
1438
+ issues.push(
1439
+ issue(
1440
+ "mc_correct_option_required",
1441
+ ["options"],
1442
+ draft.mode === "multi" ? "Mark at least one option as correct." : "Mark the correct option."
1443
+ )
1444
+ );
1445
+ } else if (draft.mode === "single" && correct > 1) {
1446
+ issues.push(
1447
+ issue(
1448
+ "mc_single_mode_one_correct",
1449
+ ["options"],
1450
+ "Only one option can be marked correct when learners select one option."
1451
+ )
1452
+ );
1453
+ }
1454
+ }
1455
+ issues.push(...checkSharedOptional(draft));
1456
+ return issues;
1457
+ }
1458
+ function checkOptionMedia(media, index, ordinal) {
1459
+ const path = ["options", index, "media"];
1460
+ const kind = media.type;
1461
+ if (kind === "video" || kind === "embed") {
1462
+ return [
1463
+ issue(
1464
+ "mc_option_media_kind",
1465
+ [...path, "type"],
1466
+ `Option ${ordinal} carries ${kind === "embed" ? "an embedded player" : "a video"}, which cannot be an option: its controls swallow the click that selects the answer. Use a picture or a recording.`
1467
+ )
1468
+ ];
1469
+ }
1470
+ return checkMedia(media, path, MultipleChoiceOptionMediaSchema);
1471
+ }
1472
+
1473
+ // src/authoring/written-response.ts
1474
+ var writtenResponseAuthoring = {
1475
+ createDraft: ({ newId }) => ({
1476
+ schemaVersion: "1.0",
1477
+ type: "written-response",
1478
+ id: newId(),
1479
+ title: "",
1480
+ prompt: "",
1481
+ minWords: 0,
1482
+ maxWords: 0
1483
+ }),
1484
+ checkDraft: checkWrittenResponseDraft
1485
+ };
1486
+ function checkWrittenResponseDraft(draft) {
1487
+ const issues = checkIdentity(draft, "written-response");
1488
+ if (isUnwritten(draft.prompt)) {
1489
+ issues.push(
1490
+ issue(
1491
+ "wr_prompt_required",
1492
+ ["prompt"],
1493
+ isUnwritten(draft.promptHtml) ? "Write the prompt." : "Write the prompt as plain text too. The rich-text prompt is shown only where a sanitiser is supplied; the plain text is shown everywhere else."
1494
+ )
1495
+ );
1496
+ }
1497
+ const min = draft.minWords;
1498
+ const max = draft.maxWords;
1499
+ if (isUnset(min)) {
1500
+ issues.push(
1501
+ issue(
1502
+ "wr_min_words_required",
1503
+ ["minWords"],
1504
+ "Set the minimum number of words. Use 0 for no minimum."
1505
+ )
1506
+ );
1507
+ } else if (!(isWholeNumber(min) && min >= 0)) {
1508
+ issues.push(
1509
+ issue(
1510
+ "wr_min_words_invalid",
1511
+ ["minWords"],
1512
+ isTooLarge(min) ? "The minimum word count is too large." : "The minimum word count must be a whole number, 0 or more."
1513
+ )
1514
+ );
1515
+ }
1516
+ if (isUnset(max) || max === 0) {
1517
+ issues.push(issue("wr_max_words_required", ["maxWords"], "Set the maximum number of words."));
1518
+ } else if (!(isWholeNumber(max) && max >= 1)) {
1519
+ issues.push(
1520
+ issue(
1521
+ "wr_max_words_invalid",
1522
+ ["maxWords"],
1523
+ isTooLarge(max) ? "The maximum word count is too large." : "The maximum word count must be a whole number, 1 or more."
1524
+ )
1525
+ );
1526
+ } else if (typeof min === "number" && max < min) {
1527
+ issues.push(
1528
+ issue(
1529
+ "wr_word_bounds_order",
1530
+ ["maxWords"],
1531
+ "The maximum word count cannot be lower than the minimum."
1532
+ )
1533
+ );
1534
+ }
1535
+ const rubric = draft.rubric;
1536
+ if (isRecord(rubric)) {
1537
+ const criteria = isUnset(rubric.criteria) ? [] : rubric.criteria;
1538
+ if (Array.isArray(criteria)) {
1539
+ issues.push(...checkCriteria(criteria));
1540
+ }
1541
+ }
1542
+ issues.push(...checkSharedOptional(draft));
1543
+ return issues;
1544
+ }
1545
+ function checkCriteria(criteria) {
1546
+ const issues = [];
1547
+ if (criteria.length === 0) {
1548
+ issues.push(
1549
+ issue(
1550
+ "wr_rubric_criteria_required",
1551
+ ["rubric", "criteria"],
1552
+ "Add at least one rubric criterion, or remove the rubric."
1553
+ )
1554
+ );
1555
+ return issues;
1556
+ }
1557
+ let totalWeight = 0;
1558
+ let everyWeightUsable = true;
1559
+ for (const [index, criterion] of criteria.entries()) {
1560
+ if (!isRecord(criterion)) {
1561
+ everyWeightUsable = false;
1562
+ continue;
1563
+ }
1564
+ const ordinal = index + 1;
1565
+ const path = ["rubric", "criteria", index];
1566
+ if (isUnwritten(criterion.name)) {
1567
+ issues.push(
1568
+ issue("wr_criterion_name_required", [...path, "name"], `Name rubric criterion ${ordinal}.`)
1569
+ );
1570
+ }
1571
+ const weight = criterion.weight;
1572
+ if (isUnset(weight)) {
1573
+ everyWeightUsable = false;
1574
+ issues.push(
1575
+ issue(
1576
+ "wr_criterion_weight_required",
1577
+ [...path, "weight"],
1578
+ `Give rubric criterion ${ordinal} a weight.`
1579
+ )
1580
+ );
1581
+ } else if (!(typeof weight === "number" && Number.isFinite(weight) && weight >= 0)) {
1582
+ everyWeightUsable = false;
1583
+ issues.push(
1584
+ issue(
1585
+ "wr_criterion_weight_invalid",
1586
+ [...path, "weight"],
1587
+ "A rubric weight must be a number, 0 or more."
1588
+ )
1589
+ );
1590
+ } else {
1591
+ totalWeight += weight;
1592
+ }
1593
+ }
1594
+ if (everyWeightUsable && totalWeight === 0) {
1595
+ issues.push(
1596
+ issue(
1597
+ "wr_rubric_weights_zero",
1598
+ ["rubric", "criteria"],
1599
+ "Give at least one rubric criterion a weight above 0. With every weight at 0, no weighted total can be computed."
1600
+ )
1601
+ );
1602
+ } else if (totalWeight === Number.POSITIVE_INFINITY) {
1603
+ issues.push(
1604
+ issue(
1605
+ "wr_rubric_weights_too_large",
1606
+ ["rubric", "criteria"],
1607
+ "The rubric weights add up to more than can be calculated with. Use smaller weights."
1608
+ )
1609
+ );
1610
+ }
1611
+ return issues;
1612
+ }
1613
+
1614
+ // src/scoring/strategies/all-or-nothing.ts
1615
+ function allOrNothingStrategy(correctItems) {
1616
+ return correctItems.every((isCorrect) => isCorrect) ? 1 : 0;
1617
+ }
1618
+
1619
+ // src/scoring/strategies/partial.ts
1620
+ function partialStrategy(correctSelected, incorrectSelected, totalCorrect, totalIncorrect) {
1621
+ const reward = correctSelected / totalCorrect;
1622
+ const penalty = totalIncorrect === 0 ? 0 : incorrectSelected / totalIncorrect;
1623
+ return Math.max(0, reward - penalty);
1624
+ }
1625
+ function partialBlankStrategy(correctBlanks, totalBlanks) {
1626
+ return correctBlanks / totalBlanks;
1627
+ }
1628
+
1629
+ // src/scoring/activity-scorers/fill-in-the-blanks.ts
1630
+ function policyFor(blank) {
1631
+ return {
1632
+ ...blank.caseSensitive !== void 0 ? { caseSensitive: blank.caseSensitive } : {},
1633
+ ...blank.trimWhitespace !== void 0 ? { trim: blank.trimWhitespace } : {},
1634
+ ...blank.match
1635
+ };
1636
+ }
1637
+ function scoreFillInTheBlanks(data, response) {
1638
+ const details = [];
1639
+ const perBlankCorrect = [];
1640
+ for (const blank of data.blanks) {
1641
+ const rawInput = response.answers[blank.id];
1642
+ const input = typeof rawInput === "string" ? rawInput : "";
1643
+ const matched = matchText(input, blank.acceptedAnswers, policyFor(blank)).matched;
1644
+ perBlankCorrect.push(matched);
1645
+ details.push({
1646
+ itemId: blank.id,
1647
+ correct: matched,
1648
+ outcome: matched ? "correct" : "incorrect",
1649
+ learnerResponse: [input],
1650
+ correctResponse: [...blank.acceptedAnswers],
1651
+ weight: 1
1652
+ });
1653
+ }
1654
+ const correctBlanks = perBlankCorrect.filter(Boolean).length;
1655
+ const scoreValue = data.scoringStrategy === "all-or-nothing" ? allOrNothingStrategy(perBlankCorrect) : partialBlankStrategy(correctBlanks, data.blanks.length);
1656
+ return { score: scoreValue, maxScore: 1, feedback: null, details };
1657
+ }
1658
+
1659
+ // src/scoring/activity-scorers/gap-select.ts
1660
+ function choicesFor(data, gap) {
1661
+ if (gap.choices !== void 0) {
1662
+ return gap.choices;
1663
+ }
1664
+ return data.banks?.find((bank) => bank.id === gap.bankId)?.choices ?? [];
1665
+ }
1666
+ function scoreGapSelect(data, response) {
1667
+ const details = [];
1668
+ const perGapCorrect = [];
1669
+ for (const gap of data.gaps) {
1670
+ const raw = response.selections[gap.id];
1671
+ const selected = typeof raw === "string" ? raw : "";
1672
+ const choices = choicesFor(data, gap);
1673
+ const offered = choices.some((choice) => choice.id === selected);
1674
+ const answered = selected !== "" && offered;
1675
+ const correct = answered && selected === gap.correctChoiceId;
1676
+ perGapCorrect.push(correct);
1677
+ details.push({
1678
+ itemId: gap.id,
1679
+ correct,
1680
+ outcome: correct ? "correct" : answered ? "incorrect" : "incorrect-omission",
1681
+ learnerResponse: [selected],
1682
+ correctResponse: [gap.correctChoiceId],
1683
+ weight: 1
1684
+ });
1685
+ }
1686
+ const correctGaps = perGapCorrect.filter(Boolean).length;
1687
+ const scoreValue = data.scoringStrategy === "all-or-nothing" ? allOrNothingStrategy(perGapCorrect) : partialBlankStrategy(correctGaps, data.gaps.length);
1688
+ return { score: scoreValue, maxScore: 1, feedback: null, details };
1689
+ }
1690
+
1691
+ // src/scoring/activity-scorers/multiple-choice.ts
1692
+ function scoreMultipleChoice(data, response) {
1693
+ const optionById = new Map(data.options.map((option) => [option.id, option]));
1694
+ const selected = new Set(response.selectedOptionIds);
1695
+ const totalCorrect = data.options.filter((option) => option.isCorrect).length;
1696
+ const totalIncorrect = data.options.length - totalCorrect;
1697
+ let scoreValue;
1698
+ if (data.scoringStrategy === "all-or-nothing") {
1699
+ if (data.mode === "single") {
1700
+ scoreValue = response.selectedOptionIds.length === 1 && optionById.get(response.selectedOptionIds[0])?.isCorrect === true ? 1 : 0;
1701
+ } else {
1702
+ const correctIds = data.options.filter((o) => o.isCorrect).map((o) => o.id);
1703
+ const allCorrectSelected = correctIds.every((id) => selected.has(id));
1704
+ scoreValue = selected.size === correctIds.length && allCorrectSelected ? 1 : 0;
1705
+ }
1706
+ } else {
1707
+ let correctSelected = 0;
1708
+ let incorrectSelected = 0;
1709
+ for (const id of selected) {
1710
+ const option = optionById.get(id);
1711
+ if (option?.isCorrect) {
1712
+ correctSelected += 1;
1713
+ } else {
1714
+ incorrectSelected += 1;
1715
+ }
1716
+ }
1717
+ scoreValue = partialStrategy(correctSelected, incorrectSelected, totalCorrect, totalIncorrect);
1718
+ }
1719
+ const details = data.options.map((option) => {
1720
+ const wasSelected = selected.has(option.id);
1721
+ const outcome = wasSelected ? option.isCorrect ? "correct" : "incorrect" : option.isCorrect ? "incorrect-omission" : "correct-omission";
1722
+ return {
1723
+ itemId: option.id,
1724
+ correct: wasSelected === option.isCorrect,
1725
+ outcome,
1726
+ learnerResponse: [wasSelected ? "selected" : "not-selected"],
1727
+ correctResponse: [option.isCorrect ? "selected" : "not-selected"],
1728
+ weight: 1
1729
+ };
1730
+ });
1731
+ return { score: scoreValue, maxScore: 1, feedback: null, details };
1732
+ }
1733
+
1734
+ // src/registry/builtins.ts
1735
+ var MEDIA_FIELD_POLICY = {
1736
+ type: "public",
1737
+ url: "public",
1738
+ alt: "public",
1739
+ captionsUrl: "public",
1740
+ playback: {
1741
+ controls: "public",
1742
+ maxPlays: "public",
1743
+ seek: "public",
1744
+ rate: "public",
1745
+ nativeControlHints: "public"
1746
+ }
1747
+ };
1748
+ var SHARED_PUBLIC_FIELDS = {
1749
+ schemaVersion: "public",
1750
+ type: "public",
1751
+ id: "public",
1752
+ // Assembly metadata, not content: it names the slot this item occupies in a
1753
+ // paper. It has to survive redaction, or the exam client derives positional
1754
+ // slot ids while the server's stored plan holds keyed ones, and the
1755
+ // responses cannot be matched back to the attempt.
1756
+ slotKey: "public",
1757
+ title: "public",
1758
+ media: MEDIA_FIELD_POLICY,
1759
+ passThreshold: "public",
1760
+ locale: "public",
1761
+ learningObjectives: "public",
1762
+ difficultyLevel: "public"
1763
+ };
1764
+ var FEEDBACK_FIELD_POLICY = {
1765
+ correct: "answer-key",
1766
+ incorrect: "answer-key"
1767
+ };
1768
+ var TEXT_MATCH_FIELD_POLICY = {
1769
+ caseSensitive: "answer-key",
1770
+ trim: "answer-key",
1771
+ normalize: "answer-key",
1772
+ foldDiacritics: "answer-key",
1773
+ collapseInnerWhitespace: "answer-key",
1774
+ ignorePunctuation: "answer-key",
1775
+ levenshtein: "answer-key",
1776
+ locale: "answer-key"
1777
+ };
1778
+ var MULTIPLE_CHOICE_FIELD_POLICY = {
1779
+ ...SHARED_PUBLIC_FIELDS,
1780
+ question: "public",
1781
+ questionHtml: "public",
1782
+ mode: "public",
1783
+ shuffle: "public",
1784
+ scoringStrategy: "answer-key",
1785
+ feedback: FEEDBACK_FIELD_POLICY,
1786
+ options: {
1787
+ id: "public",
1788
+ text: "public",
1789
+ isCorrect: "answer-key",
1790
+ feedback: "answer-key",
1791
+ // Public, and classified key by key rather than as one leaf — the lesson
1792
+ // `media`, `rubric` and `feedback` each taught: a scalar classification
1793
+ // assigns the author's object by reference without recursing, so an
1794
+ // unclassified key parked inside it survives redact() AND assertRedacted().
1795
+ // An option's picture or recording IS the thing the learner picks, so
1796
+ // stripping it would ship a row of blank options.
1797
+ media: {
1798
+ type: "public",
1799
+ url: "public",
1800
+ alt: "public",
1801
+ captionsUrl: "public"
1802
+ }
1803
+ }
1804
+ };
1805
+ var GAP_SELECT_FIELD_POLICY = {
1806
+ ...SHARED_PUBLIC_FIELDS,
1807
+ passage: "public",
1808
+ passageHtml: "public",
1809
+ presentation: "public",
1810
+ shuffleChoices: "public",
1811
+ scoringStrategy: "answer-key",
1812
+ feedback: FEEDBACK_FIELD_POLICY,
1813
+ banks: {
1814
+ id: "public",
1815
+ choices: { id: "public", text: "public" }
1816
+ },
1817
+ gaps: {
1818
+ id: "public",
1819
+ bankId: "public",
1820
+ choices: { id: "public", text: "public" },
1821
+ correctChoiceId: "answer-key",
1822
+ feedback: "answer-key"
1823
+ }
1824
+ };
1825
+ var FILL_IN_THE_BLANKS_FIELD_POLICY = {
1826
+ ...SHARED_PUBLIC_FIELDS,
1827
+ passage: "public",
1828
+ passageHtml: "public",
1829
+ scoringStrategy: "answer-key",
1830
+ feedback: FEEDBACK_FIELD_POLICY,
1831
+ blanks: {
1832
+ id: "public",
1833
+ hint: "public",
1834
+ acceptedAnswers: "answer-key",
1835
+ caseSensitive: "answer-key",
1836
+ trimWhitespace: "answer-key",
1837
+ match: TEXT_MATCH_FIELD_POLICY,
1838
+ feedback: "answer-key"
1839
+ }
1840
+ };
1841
+ var RUBRIC_FIELD_POLICY = {
1842
+ label: "public",
1843
+ // Applies to every element of the array.
1844
+ criteria: {
1845
+ name: "public",
1846
+ description: "public",
1847
+ weight: "public"
1848
+ }
1849
+ };
1850
+ var WRITTEN_RESPONSE_FIELD_POLICY = {
1851
+ ...SHARED_PUBLIC_FIELDS,
1852
+ prompt: "public",
1853
+ promptHtml: "public",
1854
+ minWords: "public",
1855
+ maxWords: "public",
1856
+ languageTarget: "public",
1857
+ feedback: FEEDBACK_FIELD_POLICY,
1858
+ // A rubric is a LEARNER affordance, not a grader secret: it tells the
1859
+ // learner what they are being graded on, which is pedagogically the point
1860
+ // of publishing one. (Classifying it author-only broke real deployments
1861
+ // that render a rubric panel during the attempt.) A deployment that wants
1862
+ // it hidden can tighten this per call via `redact(data, { policy })`.
1863
+ // Classified field by field so that stays true of the rubric's DOCUMENTED
1864
+ // fields only — see {@link RUBRIC_FIELD_POLICY}.
1865
+ rubric: RUBRIC_FIELD_POLICY
1866
+ };
1867
+ var multipleChoiceType = defineActivityType({
1868
+ type: "multiple-choice",
1869
+ // zod4 optional outputs are `T | undefined`; the hand-written wire types use
1870
+ // exact optionals. Structurally identical at runtime — cast is type-level only.
1871
+ schema: MultipleChoiceDataSchema,
1872
+ scoring: { kind: "sync", score: scoreMultipleChoice },
1873
+ isAnswered: (response) => (response?.selectedOptionIds.length ?? 0) > 0,
1874
+ fieldPolicy: MULTIPLE_CHOICE_FIELD_POLICY,
1875
+ redactedSchema: RedactedMultipleChoiceDataSchema,
1876
+ interop: {
1877
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
1878
+ xapiInteractionType: "choice",
1879
+ correctResponsesPattern: (data) => [
1880
+ data.options.filter((option) => option.isCorrect).map((option) => option.id).join("[,]")
1881
+ ]
1882
+ },
1883
+ interactions: ["option-selected", "option-deselected", "submitted"],
1884
+ authoring: multipleChoiceAuthoring
1885
+ });
1886
+ var fillInTheBlanksType = defineActivityType({
1887
+ type: "fill-in-the-blanks",
1888
+ schema: FillInTheBlanksDataSchema,
1889
+ scoring: { kind: "sync", score: scoreFillInTheBlanks },
1890
+ isAnswered: (response) => Object.values(response?.answers ?? {}).some((answer) => answer.trim().length > 0),
1891
+ fieldPolicy: FILL_IN_THE_BLANKS_FIELD_POLICY,
1892
+ redactedSchema: RedactedFillInTheBlanksDataSchema,
1893
+ interop: {
1894
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
1895
+ xapiInteractionType: "fill-in",
1896
+ // xAPI fill-in pattern: blank answers joined with "[,]". Only the first
1897
+ // accepted answer per blank is emitted (full alternates would explode
1898
+ // combinatorially); the complete key lives in the activity data.
1899
+ correctResponsesPattern: (data) => [
1900
+ data.blanks.map((blank) => blank.acceptedAnswers[0] ?? "").join("[,]")
1901
+ ]
1902
+ },
1903
+ interactions: ["blank-filled", "hint-requested", "submitted"],
1904
+ authoring: fillInTheBlanksAuthoring
1905
+ });
1906
+ var writtenResponseType = defineActivityType({
1907
+ type: "written-response",
1908
+ schema: WrittenResponseDataSchema,
1909
+ scoring: {
1910
+ kind: "deferred",
1911
+ reason: "requires_async_grading",
1912
+ partial: (data, response) => {
1913
+ const wordCount = countWords(response?.text ?? "");
1914
+ return {
1915
+ withinWordBounds: response !== void 0 && wordCount >= data.minWords && wordCount <= data.maxWords,
1916
+ wordCount
1917
+ };
1918
+ }
1919
+ },
1920
+ isAnswered: (response) => (response?.text.trim().length ?? 0) > 0,
1921
+ fieldPolicy: WRITTEN_RESPONSE_FIELD_POLICY,
1922
+ redactedSchema: RedactedWrittenResponseDataSchema,
1923
+ interop: {
1924
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
1925
+ xapiInteractionType: "long-fill-in",
1926
+ correctResponsesPattern: () => []
1927
+ },
1928
+ interactions: ["text-changed", "submitted"],
1929
+ authoring: writtenResponseAuthoring
1930
+ });
1931
+ var gapSelectType = defineActivityType({
1932
+ type: "gap-select",
1933
+ schema: GapSelectDataSchema,
1934
+ scoring: { kind: "sync", score: scoreGapSelect },
1935
+ isAnswered: (response) => Object.values(response?.selections ?? {}).some((choiceId) => choiceId !== ""),
1936
+ fieldPolicy: GAP_SELECT_FIELD_POLICY,
1937
+ redactedSchema: RedactedGapSelectDataSchema,
1938
+ interop: {
1939
+ xapiActivityTypeIri: "http://adlnet.gov/expapi/activities/cmi.interaction",
1940
+ // `matching`, not `choice` or `fill-in`. The learner pairs a set of sources
1941
+ // (the gaps) with a set of targets (the choices), which is exactly what the
1942
+ // xAPI matching interaction describes, and it is the only built-in type
1943
+ // whose pattern can name WHICH gap took which answer. `fill-in` — what the
1944
+ // Fill-in-the-Blanks descriptor uses — would flatten the gaps into an
1945
+ // ordered list of strings and lose that.
1946
+ xapiInteractionType: "matching",
1947
+ correctResponsesPattern: (data) => [
1948
+ data.gaps.map((gap) => `${gap.id}[.]${gap.correctChoiceId}`).join("[,]")
1949
+ ]
1950
+ },
1951
+ interactions: ["gap-selected", "submitted"],
1952
+ authoring: gapSelectAuthoring
1953
+ });
1954
+ registerActivityType(multipleChoiceType);
1955
+ registerActivityType(fillInTheBlanksType);
1956
+ registerActivityType(writtenResponseType);
1957
+ registerActivityType(gapSelectType);
1958
+
1959
+ export {
1960
+ ActivitySchemaError,
1961
+ UnknownActivityTypeError,
1962
+ RedactedScoringError,
1963
+ DeferredScoringError,
1964
+ FeedbackSchema,
1965
+ MediaUrlSchema,
1966
+ NativeControlHintSchema,
1967
+ MediaPlaybackSchema,
1968
+ MediaSchema,
1969
+ RedactedMediaSchema,
1970
+ TextMatchPolicySchema,
1971
+ BlankConfigSchema,
1972
+ FillInTheBlanksDataSchema,
1973
+ DRAFT_ISSUE_SEVERITY,
1974
+ issue,
1975
+ isRecord,
1976
+ refusesEmpty,
1977
+ MultipleChoiceOptionMediaSchema,
1978
+ MultipleChoiceOptionSchema,
1979
+ MultipleChoiceDataSchema,
1980
+ countWords,
1981
+ GapSelectChoiceSchema,
1982
+ GapSelectBankSchema,
1983
+ GapSelectGapSchema,
1984
+ GapSelectDataSchema,
1985
+ WrittenResponseRubricCriterionSchema,
1986
+ WrittenResponseRubricSchema,
1987
+ WrittenResponseDataSchema,
1988
+ RedactedMultipleChoiceOptionMediaSchema,
1989
+ RedactedMultipleChoiceOptionSchema,
1990
+ RedactedMultipleChoiceDataSchema,
1991
+ RedactedBlankConfigSchema,
1992
+ RedactedFillInTheBlanksDataSchema,
1993
+ RedactedGapSelectChoiceSchema,
1994
+ RedactedGapSelectBankSchema,
1995
+ RedactedGapSelectGapSchema,
1996
+ RedactedGapSelectDataSchema,
1997
+ RedactedWrittenResponseDataSchema,
1998
+ levenshteinDistance,
1999
+ matchText,
2000
+ defineActivityType,
2001
+ registerActivityType,
2002
+ getActivityTypeDescriptor,
2003
+ registeredActivityTypes,
2004
+ MEDIA_FIELD_POLICY,
2005
+ multipleChoiceType,
2006
+ fillInTheBlanksType,
2007
+ writtenResponseType
2008
+ };
2009
+ //# sourceMappingURL=chunk-FIS5KBCE.js.map