@pie-element/mc-populated-blank 0.3.0-next.8 → 0.3.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 (53) hide show
  1. package/README.md +51 -21
  2. package/author.js +2 -0
  3. package/dist/author/defaults.d.ts +73 -0
  4. package/dist/author/index.d.ts +2 -10
  5. package/dist/author/index.js +139 -165
  6. package/dist/author/index.js.map +1 -1
  7. package/dist/browser/McPopulatedBlank-LqV-pdXv.js +2541 -0
  8. package/dist/browser/McPopulatedBlank-LqV-pdXv.js.map +1 -0
  9. package/dist/browser/author/index.js +11 -32
  10. package/dist/browser/author/index.js.map +1 -1
  11. package/dist/browser/{client-BmZWrsT3.js → client-Dfdyltec.js} +655 -643
  12. package/dist/browser/client-Dfdyltec.js.map +1 -0
  13. package/dist/browser/controller/index.js +2 -220
  14. package/dist/browser/controller-DSkEI2th.js +222 -0
  15. package/dist/browser/controller-DSkEI2th.js.map +1 -0
  16. package/dist/browser/delivery/index.js +71 -1005
  17. package/dist/browser/delivery/index.js.map +1 -1
  18. package/dist/browser/{layoutLimits-Bp17YP2V.js → layoutLimits-C2D60jW0.js} +1 -2
  19. package/dist/browser/layoutLimits-C2D60jW0.js.map +1 -0
  20. package/dist/browser/print/index.js +94 -103
  21. package/dist/browser/print/index.js.map +1 -1
  22. package/dist/controller/defaults.d.ts +8 -30
  23. package/dist/controller/index.d.ts +80 -65
  24. package/dist/controller/index.js +142 -115
  25. package/dist/controller/index.js.map +1 -1
  26. package/dist/delivery/assets/audioButtonSkinAssets.d.ts +4 -0
  27. package/dist/delivery/computeDisplayState.d.ts +12 -7
  28. package/dist/delivery/computeLayoutProfile.d.ts +8 -0
  29. package/dist/delivery/computeLayoutStyle.d.ts +1 -1
  30. package/dist/delivery/i18n.d.ts +8 -0
  31. package/dist/delivery/index.d.ts +8 -16
  32. package/dist/delivery/index.js +3229 -719
  33. package/dist/delivery/index.js.map +1 -1
  34. package/dist/delivery/variant-css-map.d.ts +15 -2
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.iife.d.ts +1 -154
  37. package/dist/index.iife.js +414 -163
  38. package/dist/index.js +2 -6818
  39. package/dist/print/index.d.ts +3 -7
  40. package/dist/print/index.js +4667 -349
  41. package/dist/print/index.js.map +1 -1
  42. package/dist/print/preparePrint.d.ts +15 -0
  43. package/dist/runtime-support.js +5 -5
  44. package/dist/runtime-support.js.map +1 -1
  45. package/dist/shared/layoutLimits.d.ts +0 -1
  46. package/dist/shared/types.d.ts +14 -20
  47. package/module/print.js +3781 -918
  48. package/module/print.js.map +1 -1
  49. package/package.json +14 -4
  50. package/dist/browser/client-BmZWrsT3.js.map +0 -1
  51. package/dist/browser/controller/index.js.map +0 -1
  52. package/dist/browser/layoutLimits-Bp17YP2V.js.map +0 -1
  53. package/dist/index.js.map +0 -1
@@ -1,8 +1,11 @@
1
- import type { McpbChoice, McpbQuestion, McpbSession, McpbEnv, McpbCorrectness } from '../shared/types';
1
+ import type { PieUpdateSession } from '@pie-element/shared-types';
2
+ import type { McpbChoice, McpbQuestion, McpbSession, McpbEnv, McpbCorrectness } from '../shared/types.js';
2
3
  export declare function countBlankTokens(template: string): number;
3
4
  export declare const getCorrectness: (question: McpbQuestion, session: McpbSession) => McpbCorrectness;
4
- export declare const getPartialScore: (_question: McpbQuestion, session: McpbSession) => 0 | 1;
5
+ /** One blank, one key: an answered session scores 1 only when it picked the key. */
6
+ export declare const getPartialScore: (question: McpbQuestion, session: McpbSession) => 1 | 0;
5
7
  export declare const outcome: (question: McpbQuestion, session: McpbSession, env: McpbEnv) => Promise<unknown>;
8
+ /** The authoring starting point: a question that passes `validate()` as it stands. */
6
9
  export declare const createDefaultModel: (model?: McpbQuestion) => {
7
10
  layoutLimits: {
8
11
  blankStandaloneWidthRem: number;
@@ -26,7 +29,6 @@ export declare const createDefaultModel: (model?: McpbQuestion) => {
26
29
  audioBlankTemplateMarginTopRem: number;
27
30
  audioBlankTemplateMarginBottomRem: number;
28
31
  audioInstructionsMaxWidthPx: number;
29
- narrowHorizontalChoiceMaxWidthPx: number;
30
32
  stimulusGridColumnGapRem: number;
31
33
  stimulusGridRowGapRem: number;
32
34
  stimulusSentenceMarginTopRem: number;
@@ -41,39 +43,44 @@ export declare const createDefaultModel: (model?: McpbQuestion) => {
41
43
  inlineTemplateMarginTopRem: number;
42
44
  inlineChoicesMarginTopRem: number;
43
45
  };
44
- id: string;
45
- element: string;
46
- prompt: string;
47
- promptEnabled: boolean;
48
- interactionMode: import("../shared/types").McpbInteractionMode;
49
- layoutProfile: string;
50
- choiceLayout: string;
51
- layoutProfilePresets: {};
52
- audioButtonSkin: import("../shared/types").McpbAudioButtonSkin | null;
53
- audioButtonSkinsByLocale: {};
54
- uiText: import("../shared/types").McpbUiText;
55
- sentenceHtml: string;
56
- template: string;
57
- choiceMode: import("../shared/types").McpbChoiceMode;
58
- choices: McpbChoice[] | {
59
- id: string;
60
- labelHtml: string;
61
- }[];
62
- correctChoiceId: string;
63
- hasAudio: boolean;
64
- autoplayAudioEnabled: boolean;
65
- completeAudioEnabled: boolean;
66
- audioUrl: string;
67
- audioTranscript: string;
46
+ id?: string;
47
+ element?: string;
48
+ prompt?: string;
49
+ promptEnabled?: boolean;
50
+ interactionMode?: import("../shared/types.js").McpbInteractionMode;
51
+ layoutProfile?: string;
52
+ choiceLayout?: string;
53
+ layoutProfilePresets?: Record<string, Partial<import("../shared/layoutLimits.js").LayoutLimits>>;
54
+ audioButtonSkin?: import("../shared/types.js").McpbAudioButtonSkin | null;
55
+ audioButtonSkinsByLocale?: Record<string, import("../shared/types.js").McpbAudioButtonSkin>;
56
+ sentenceHtml?: string;
57
+ template?: string;
58
+ choiceMode?: import("../shared/types.js").McpbChoiceMode;
59
+ choices?: McpbChoice[];
60
+ choiceGroupLabel?: string;
61
+ correctChoiceId?: string;
62
+ hasAudio?: boolean;
63
+ autoplayAudioEnabled?: boolean;
64
+ completeAudioEnabled?: boolean;
65
+ audioUrl?: string;
66
+ audioTranscript?: string;
68
67
  useFeatureButtonAudio?: boolean;
69
- locale: string;
70
- shuffle?: boolean;
68
+ locale?: string;
69
+ language?: string;
71
70
  lockChoiceOrder?: boolean;
72
- teacherInstructions: string;
73
- teacherInstructionsEnabled: boolean;
71
+ shuffle?: boolean;
72
+ teacherInstructions?: string;
73
+ teacherInstructionsEnabled?: boolean;
74
+ printAnswerKey?: boolean;
74
75
  customType?: string;
75
76
  };
77
+ /**
78
+ * What `model()` and `outcome()` read: the item's own fields, with neutral
79
+ * values for the rest. Starter content here would reach a learner as the
80
+ * item's prompt or choices.
81
+ */
76
82
  export declare const normalize: (question?: McpbQuestion) => {
83
+ lockChoiceOrder: boolean;
77
84
  layoutLimits: {
78
85
  blankStandaloneWidthRem: number;
79
86
  blankWideWidthRem: number;
@@ -96,7 +103,6 @@ export declare const normalize: (question?: McpbQuestion) => {
96
103
  audioBlankTemplateMarginTopRem: number;
97
104
  audioBlankTemplateMarginBottomRem: number;
98
105
  audioInstructionsMaxWidthPx: number;
99
- narrowHorizontalChoiceMaxWidthPx: number;
100
106
  stimulusGridColumnGapRem: number;
101
107
  stimulusGridRowGapRem: number;
102
108
  stimulusSentenceMarginTopRem: number;
@@ -111,43 +117,52 @@ export declare const normalize: (question?: McpbQuestion) => {
111
117
  inlineTemplateMarginTopRem: number;
112
118
  inlineChoicesMarginTopRem: number;
113
119
  };
114
- id: string;
115
- element: string;
116
- prompt: string;
117
- promptEnabled: boolean;
118
- interactionMode: import("../shared/types").McpbInteractionMode;
119
- layoutProfile: string;
120
- choiceLayout: string;
121
- layoutProfilePresets: {};
122
- audioButtonSkin: import("../shared/types").McpbAudioButtonSkin | null;
123
- audioButtonSkinsByLocale: {};
124
- uiText: import("../shared/types").McpbUiText;
125
- sentenceHtml: string;
126
- template: string;
127
- choiceMode: import("../shared/types").McpbChoiceMode;
128
- choices: McpbChoice[] | {
129
- id: string;
130
- labelHtml: string;
131
- }[];
132
- correctChoiceId: string;
133
- hasAudio: boolean;
134
- autoplayAudioEnabled: boolean;
135
- completeAudioEnabled: boolean;
136
- audioUrl: string;
137
- audioTranscript: string;
120
+ id?: string;
121
+ element?: string;
122
+ prompt?: string;
123
+ promptEnabled?: boolean;
124
+ interactionMode?: import("../shared/types.js").McpbInteractionMode;
125
+ layoutProfile?: string;
126
+ choiceLayout?: string;
127
+ layoutProfilePresets?: Record<string, Partial<import("../shared/layoutLimits.js").LayoutLimits>>;
128
+ audioButtonSkin?: import("../shared/types.js").McpbAudioButtonSkin | null;
129
+ audioButtonSkinsByLocale?: Record<string, import("../shared/types.js").McpbAudioButtonSkin>;
130
+ sentenceHtml?: string;
131
+ template?: string;
132
+ choiceMode?: import("../shared/types.js").McpbChoiceMode;
133
+ choices?: McpbChoice[];
134
+ choiceGroupLabel?: string;
135
+ correctChoiceId?: string;
136
+ hasAudio?: boolean;
137
+ autoplayAudioEnabled?: boolean;
138
+ completeAudioEnabled?: boolean;
139
+ audioUrl?: string;
140
+ audioTranscript?: string;
138
141
  useFeatureButtonAudio?: boolean;
139
- locale: string;
142
+ locale?: string;
143
+ language?: string;
140
144
  shuffle?: boolean;
141
- lockChoiceOrder?: boolean;
142
- teacherInstructions: string;
143
- teacherInstructionsEnabled: boolean;
145
+ teacherInstructions?: string;
146
+ teacherInstructionsEnabled?: boolean;
147
+ printAnswerKey?: boolean;
144
148
  customType?: string;
145
149
  };
146
150
  export declare const normalizeSession: (s: McpbSession) => McpbSession;
147
- type UpdateSessionFn = (id: string, element: string, data: {
148
- shuffledValues: string[];
149
- }) => Promise<void>;
150
- export declare const model: (question: McpbQuestion, session: McpbSession | null, env: McpbEnv | null, updateSession?: UpdateSessionFn) => Promise<Record<string, unknown>>;
151
+ export declare const model: (question: McpbQuestion, session: McpbSession | null, env: McpbEnv | null, updateSession?: PieUpdateSession) => Promise<Record<string, unknown>>;
151
152
  export declare const createCorrectResponseSession: (question: McpbQuestion, env: McpbEnv) => Promise<unknown>;
152
- export declare const validate: (question?: McpbQuestion, _config?: Record<string, unknown>) => Record<string, string>;
153
+ type ValidateConfig = {
154
+ minAnswerChoices?: number;
155
+ maxAnswerChoices?: number;
156
+ prompt?: {
157
+ required?: boolean;
158
+ };
159
+ teacherInstructions?: {
160
+ required?: boolean;
161
+ };
162
+ };
163
+ /**
164
+ * Authoring errors in multiple-choice's shape: a message per field, and under
165
+ * `choices` a message per choice keyed by its id.
166
+ */
167
+ export declare const validate: (question?: McpbQuestion, config?: ValidateConfig) => Record<string, string | Record<string, string>>;
153
168
  export {};
@@ -1,3 +1,39 @@
1
+ //#region ../../shared/controller-utils/dist/index.js
2
+ function n(e) {
3
+ return Array.isArray(e) ? e.filter((e) => e != null) : [];
4
+ }
5
+ function r(e) {
6
+ let t = [...e];
7
+ for (let e = t.length - 1; e > 0; e--) {
8
+ let n = Math.floor(Math.random() * (e + 1));
9
+ [t[e], t[n]] = [t[n], t[e]];
10
+ }
11
+ return t;
12
+ }
13
+ function i(e) {
14
+ return e == null ? !0 : Array.isArray(e) ? e.length === 0 : typeof e == "object" && Object.keys(e).length === 0;
15
+ }
16
+ async function a(e, t, a, o = "value") {
17
+ let s = n(t?.data?.shuffledValues ?? t?.shuffledValues ?? []);
18
+ if (!t) {
19
+ console.warn("Unable to save shuffled choices because there's no session.");
20
+ return;
21
+ }
22
+ if (!i(s)) return n(s.map((t) => e.find((e) => e[o] === t)));
23
+ let c = r(e);
24
+ if (a && typeof a == "function") try {
25
+ let e = n(c.map((e) => e[o]));
26
+ i(e) ? console.error(`shuffledValues is an empty array - refusing to call updateSession. shuffledChoices: ${JSON.stringify(c)}, key: ${o}`) : await a(t.id, t.element, { shuffledValues: e });
27
+ } catch (e) {
28
+ console.warn("Unable to save shuffled order for choices"), console.error(e);
29
+ }
30
+ else console.warn("Unable to save shuffled choices, shuffle will happen every time.");
31
+ return c;
32
+ }
33
+ function o(e, t, n) {
34
+ return !!(e.lockChoiceOrder || n?.["@pie-element"]?.lockChoiceOrder);
35
+ }
36
+ //#endregion
1
37
  //#region src/shared/layoutLimits.ts
2
38
  /** Baseline values tuned to match historical CQT visuals; override via model.layoutLimits. */
3
39
  var DEFAULT_LAYOUT_LIMITS = {
@@ -22,7 +58,6 @@ var DEFAULT_LAYOUT_LIMITS = {
22
58
  audioBlankTemplateMarginTopRem: .8,
23
59
  audioBlankTemplateMarginBottomRem: 1.8,
24
60
  audioInstructionsMaxWidthPx: 875,
25
- narrowHorizontalChoiceMaxWidthPx: 230,
26
61
  stimulusGridColumnGapRem: 2,
27
62
  stimulusGridRowGapRem: .7,
28
63
  stimulusSentenceMarginTopRem: .2,
@@ -41,38 +76,44 @@ var DEFAULT_LAYOUT_LIMITS = {
41
76
  //#region src/controller/defaults.ts
42
77
  /** Token authors must include exactly once in `template` (HTML). */
43
78
  var BLANK_TOKEN = "{{blank}}";
79
+ /**
80
+ * The values `normalize` fills in for fields an item leaves out. They carry no
81
+ * content and no `id` or `element`: those come from the item config, and a
82
+ * default here would stand in for a field the author never set.
83
+ */
84
+ var defaults_default$1 = { model: {
85
+ prompt: "",
86
+ promptEnabled: true,
87
+ interactionMode: "populate_blank",
88
+ layoutProfile: "",
89
+ choiceLayout: "",
90
+ layoutProfilePresets: {},
91
+ layoutLimits: { ...DEFAULT_LAYOUT_LIMITS },
92
+ audioButtonSkin: null,
93
+ audioButtonSkinsByLocale: {},
94
+ sentenceHtml: "",
95
+ template: "",
96
+ choiceMode: "text",
97
+ choices: [],
98
+ choiceGroupLabel: "",
99
+ correctChoiceId: "",
100
+ hasAudio: false,
101
+ autoplayAudioEnabled: false,
102
+ completeAudioEnabled: false,
103
+ audioUrl: "",
104
+ audioTranscript: "",
105
+ locale: "",
106
+ teacherInstructions: "",
107
+ teacherInstructionsEnabled: true
108
+ } };
109
+ //#endregion
110
+ //#region src/author/defaults.ts
111
+ /** The model a new item starts from: a valid question an author edits in place. */
44
112
  var defaults_default = {
45
113
  model: {
46
- id: "1",
47
- element: "mc-populated-blank",
114
+ ...defaults_default$1.model,
48
115
  prompt: "<p><strong>Demo prompt</strong></p>",
49
- promptEnabled: true,
50
- interactionMode: "populate_blank",
51
- layoutProfile: "",
52
- choiceLayout: "",
53
- layoutProfilePresets: {},
54
- layoutLimits: { ...DEFAULT_LAYOUT_LIMITS },
55
- audioButtonSkin: null,
56
- audioButtonSkinsByLocale: {},
57
- uiText: {
58
- answerChoices: "Answer choices",
59
- selectedAnswerInSentence: "blank",
60
- blankPreSelectionHint: "The answer you choose will appear on the blank line above.",
61
- showCorrectAnswer: "Show correct answer",
62
- hideCorrectAnswer: "Hide correct answer",
63
- clickToEnableAutoplay: "Click to enable audio autoplay",
64
- audioResourceUnavailable: "Audio is enabled but no playable audio URL is configured.",
65
- transcriptLabel: "Transcript",
66
- listenLabelEn: "Listen",
67
- listenLabelEs: "Escuchar",
68
- listenSilentAlt: "Repeat instructions",
69
- listenPlayingAlt: "Instructions are playing",
70
- listenSilentAltEs: "Escuchar. Repetir las instrucciones.",
71
- listenPlayingAltEs: "Escuchar. Estas son las instrucciones."
72
- },
73
- sentenceHtml: "",
74
116
  template: `<p>The answer is ${BLANK_TOKEN}.</p>`,
75
- choiceMode: "text",
76
117
  choices: [
77
118
  {
78
119
  id: "c1",
@@ -87,15 +128,7 @@ var defaults_default = {
87
128
  labelHtml: "<p>Option C</p>"
88
129
  }
89
130
  ],
90
- correctChoiceId: "c2",
91
- hasAudio: false,
92
- autoplayAudioEnabled: false,
93
- completeAudioEnabled: false,
94
- audioUrl: "",
95
- audioTranscript: "",
96
- locale: "",
97
- teacherInstructions: "",
98
- teacherInstructionsEnabled: false
131
+ correctChoiceId: "c2"
99
132
  },
100
133
  configuration: { prompt: {
101
134
  label: "Prompt",
@@ -115,10 +148,8 @@ var getCorrectness = (question, session) => {
115
148
  if (session.choiceId === correct) return "correct";
116
149
  return "incorrect";
117
150
  };
118
- var getPartialScore = (_question, session) => {
119
- if (!session || isEmptyObject(session) || !session.choiceId) return 0;
120
- return 1;
121
- };
151
+ /** One blank, one key: an answered session scores 1 only when it picked the key. */
152
+ var getPartialScore = (question, session) => getCorrectness(question, session) === "correct" ? 1 : 0;
122
153
  var outcome = (question, session, env) => new Promise((resolve) => {
123
154
  if (!session || isEmptyObject(session)) {
124
155
  resolve({
@@ -129,8 +160,8 @@ var outcome = (question, session, env) => new Promise((resolve) => {
129
160
  return;
130
161
  }
131
162
  session = normalizeSession(session);
132
- const correctness = getCorrectness(question, session);
133
- if (correctness === "unanswered") {
163
+ const normalizedQuestion = normalize(question);
164
+ if (getCorrectness(normalizedQuestion, session) === "unanswered") {
134
165
  resolve({
135
166
  score: 0,
136
167
  empty: true,
@@ -138,68 +169,58 @@ var outcome = (question, session, env) => new Promise((resolve) => {
138
169
  });
139
170
  return;
140
171
  }
141
- const score = correctness === "correct" ? 1 : 0;
172
+ const score = getPartialScore(normalizedQuestion, session);
142
173
  resolve({
143
174
  score,
144
175
  empty: false,
145
176
  traceLog: [
146
177
  `Mode: ${env?.mode || "unknown"}.`,
147
178
  `Student selected choice: ${session.choiceId}.`,
148
- `Correct choice: ${question?.correctChoiceId || "none"}.`,
179
+ `Correct choice: ${normalizedQuestion.correctChoiceId || "none"}.`,
149
180
  `Final score: ${score}.`
150
181
  ]
151
182
  });
152
183
  });
153
- var createDefaultModel = (model = {}) => ({
154
- ...defaults_default.model,
184
+ var withDefaults = (base, model = {}) => ({
185
+ ...base,
155
186
  ...model,
156
187
  layoutLimits: {
157
188
  ...DEFAULT_LAYOUT_LIMITS,
158
189
  ...model?.layoutLimits || {}
159
190
  }
160
191
  });
161
- var normalize = (question = {}) => createDefaultModel(question);
162
- var normalizeSession = (s) => ({ ...s });
163
- var shouldShuffleChoices = (question) => !!question?.shuffle;
164
- var shouldLockChoices = (question, env) => {
165
- if (question?.lockChoiceOrder) return true;
166
- if (env?.["@pie-element"]?.lockChoiceOrder) return true;
167
- return env?.role === "instructor";
168
- };
169
- function shuffleArray(items) {
170
- const out = [...items];
171
- for (let i = out.length - 1; i > 0; i--) {
172
- const j = Math.floor(Math.random() * (i + 1));
173
- [out[i], out[j]] = [out[j], out[i]];
174
- }
175
- return out;
176
- }
177
- var getStoredShuffle = (session) => Array.isArray(session?.data?.shuffledValues) ? session.data.shuffledValues : Array.isArray(session?.shuffledValues) ? session.shuffledValues : [];
178
- var applyShuffledValues = (choices, shuffledValues, choiceKey) => {
179
- const orderedChoices = shuffledValues.map((value) => choices.find((choice) => choice?.[choiceKey] === value)).filter((c) => !!c);
180
- if (orderedChoices.length === choices.length) return orderedChoices;
181
- const orderedValues = new Set(orderedChoices.map((choice) => choice[choiceKey]));
182
- const leftovers = choices.filter((choice) => !orderedValues.has(choice?.[choiceKey]));
183
- return [...orderedChoices, ...leftovers];
184
- };
185
- var getOrderedChoices = async (question, session, env, updateSession) => {
186
- const choices = Array.isArray(question?.choices) ? [...question.choices] : [];
187
- if (!choices.length || !shouldShuffleChoices(question)) return choices;
188
- if (shouldLockChoices(question, env || {})) return choices;
189
- const shuffledValues = getStoredShuffle(session);
190
- if (shuffledValues.length) return applyShuffledValues(choices, shuffledValues, "id");
191
- const shuffledChoices = shuffleArray(choices);
192
- if (updateSession && typeof updateSession === "function" && session?.id && session?.element) {
193
- const shuffledIds = shuffledChoices.map((choice) => choice?.id).filter(Boolean);
194
- if (shuffledIds.length) await updateSession(session.id, session.element, { shuffledValues: shuffledIds });
195
- }
196
- return shuffledChoices;
192
+ /** The authoring starting point: a question that passes `validate()` as it stands. */
193
+ var createDefaultModel = (model = {}) => withDefaults(defaults_default.model, model);
194
+ /**
195
+ * What `model()` and `outcome()` read: the item's own fields, with neutral
196
+ * values for the rest. Starter content here would reach a learner as the
197
+ * item's prompt or choices.
198
+ */
199
+ var normalize = (question = {}) => {
200
+ const { shuffle, ...rest } = question || {};
201
+ return {
202
+ ...withDefaults(defaults_default$1.model, rest),
203
+ lockChoiceOrder: typeof rest.lockChoiceOrder === "boolean" ? rest.lockChoiceOrder : shuffle !== true
204
+ };
197
205
  };
206
+ var normalizeSession = (s) => ({ ...s });
207
+ /**
208
+ * The fields delivery renders. An imported choice can carry more, such as a
209
+ * correctness flag or feedback, which must not reach a learner's browser.
210
+ */
211
+ var toDeliveryChoice = ({ id, labelHtml, imageUrl, imageAlt }) => ({
212
+ id,
213
+ ...labelHtml !== void 0 ? { labelHtml } : {},
214
+ ...imageUrl !== void 0 ? { imageUrl } : {},
215
+ ...imageAlt !== void 0 ? { imageAlt } : {}
216
+ });
198
217
  var model = async (question, session, env, updateSession) => {
199
218
  const safeSession = session || {};
200
219
  const safeEnv = env || {};
201
220
  const normalizedQuestion = normalize(question);
202
- const choices = await getOrderedChoices(normalizedQuestion, safeSession, safeEnv, updateSession);
221
+ let orderedChoices = Array.isArray(normalizedQuestion.choices) ? [...normalizedQuestion.choices] : [];
222
+ if (orderedChoices.length && !o(normalizedQuestion, safeSession, safeEnv)) orderedChoices = await a(orderedChoices, safeSession, updateSession, "id") ?? orderedChoices;
223
+ const choices = orderedChoices.filter((c) => !!c && typeof c === "object").map(toDeliveryChoice);
203
224
  const out = {
204
225
  prompt: normalizedQuestion.promptEnabled ? normalizedQuestion.prompt : null,
205
226
  interactionMode: normalizedQuestion.interactionMode || "populate_blank",
@@ -209,11 +230,11 @@ var model = async (question, session, env, updateSession) => {
209
230
  layoutLimits: normalizedQuestion.layoutLimits || { ...DEFAULT_LAYOUT_LIMITS },
210
231
  audioButtonSkin: normalizedQuestion.audioButtonSkin && typeof normalizedQuestion.audioButtonSkin === "object" ? normalizedQuestion.audioButtonSkin : null,
211
232
  audioButtonSkinsByLocale: normalizedQuestion.audioButtonSkinsByLocale && typeof normalizedQuestion.audioButtonSkinsByLocale === "object" ? normalizedQuestion.audioButtonSkinsByLocale : {},
212
- uiText: normalizedQuestion.uiText && typeof normalizedQuestion.uiText === "object" ? normalizedQuestion.uiText : {},
213
233
  sentenceHtml: normalizedQuestion.sentenceHtml || null,
214
234
  template: normalizedQuestion.template,
215
235
  choiceMode: normalizedQuestion.choiceMode,
216
236
  choices,
237
+ choiceGroupLabel: normalizedQuestion.choiceGroupLabel || "",
217
238
  hasAudio: normalizedQuestion.hasAudio,
218
239
  autoplayAudioEnabled: !!normalizedQuestion.autoplayAudioEnabled,
219
240
  completeAudioEnabled: !!normalizedQuestion.completeAudioEnabled,
@@ -222,6 +243,7 @@ var model = async (question, session, env, updateSession) => {
222
243
  customType: normalizedQuestion.customType || "",
223
244
  useFeatureButtonAudio: typeof normalizedQuestion.useFeatureButtonAudio === "boolean" ? normalizedQuestion.useFeatureButtonAudio : void 0,
224
245
  locale: normalizedQuestion.locale || "",
246
+ language: normalizedQuestion.language || normalizedQuestion.locale || void 0,
225
247
  disabled: safeEnv.mode !== "gather",
226
248
  mode: safeEnv.mode
227
249
  };
@@ -239,18 +261,22 @@ var createCorrectResponseSession = (question, env) => {
239
261
  return new Promise((resolve) => {
240
262
  if (env.mode !== "evaluate" && env.role === "instructor") resolve({
241
263
  id: "1",
242
- element: "mc-populated-blank",
243
264
  choiceId: question?.correctChoiceId || ""
244
265
  });
245
266
  else resolve(null);
246
267
  });
247
268
  };
248
- var validate = (question = {}, _config = {}) => {
269
+ /** Markup with its tags removed, keeping media an author can answer from. */
270
+ var getContent = (html) => (html || "").replace(/(<(?!img|iframe|source)([^>]+)>)/gi, "").trim();
271
+ /**
272
+ * Authoring errors in multiple-choice's shape: a message per field, and under
273
+ * `choices` a message per choice keyed by its id.
274
+ */
275
+ var validate = (question = {}, config = {}) => {
249
276
  const errors = {};
250
- if (question.promptEnabled) {
251
- const p = question.prompt?.trim() || "";
252
- if (!p || p === "<p></p>") errors.prompt = "Prompt is required when prompt is enabled";
253
- }
277
+ const { minAnswerChoices = 2, maxAnswerChoices } = config;
278
+ if ((question.promptEnabled || config.prompt?.required) && !getContent(question.prompt)) errors.prompt = "This field is required.";
279
+ if (config.teacherInstructions?.required && !getContent(question.teacherInstructions)) errors.teacherInstructions = "This field is required.";
254
280
  const interactionMode = question.interactionMode || "populate_blank";
255
281
  const n = countBlankTokens(question.template || "");
256
282
  if (interactionMode === "audio_mc_only") {
@@ -260,33 +286,35 @@ var validate = (question = {}, _config = {}) => {
260
286
  else if (n > 1) errors.template = `Template must contain only one ${BLANK_TOKEN} placeholder`;
261
287
  } else errors.interactionMode = "Unknown interaction mode";
262
288
  const choices = Array.isArray(question.choices) ? question.choices : [];
263
- if (choices.length < 2) errors.choices = "At least two choices are required";
289
+ if (choices.length < minAnswerChoices) errors.answerChoices = `There should be at least ${minAnswerChoices} choices defined.`;
290
+ else if (maxAnswerChoices != null && choices.length > maxAnswerChoices) errors.answerChoices = `No more than ${maxAnswerChoices} choices should be defined.`;
264
291
  const mode = question.choiceMode || "text";
265
- for (let i = 0; i < choices.length; i++) {
266
- const c = choices[i];
292
+ const choicesErrors = {};
293
+ const seenIds = /* @__PURE__ */ new Set();
294
+ const seenLabels = /* @__PURE__ */ new Set();
295
+ choices.forEach((c, i) => {
296
+ const key = c?.id || String(i);
267
297
  if (!c?.id) {
268
- errors.choices = `Choice ${i + 1} is missing an id`;
269
- break;
298
+ choicesErrors[key] = "Choice needs an id.";
299
+ return;
270
300
  }
271
- if (mode === "text") {
272
- const lbl = (c.labelHtml || "").trim();
273
- if (!lbl || lbl === "<p></p>") {
274
- errors.choices = `Choice ${i + 1} needs label text`;
275
- break;
276
- }
277
- } else {
278
- if (!c.imageUrl?.trim()) {
279
- errors.choices = `Choice ${i + 1} needs an image URL`;
280
- break;
281
- }
282
- if (!c.imageAlt?.trim()) {
283
- errors.choices = `Choice ${i + 1} needs image alt text`;
284
- break;
285
- }
301
+ if (seenIds.has(c.id)) {
302
+ choicesErrors[key] = "Choice id should be unique.";
303
+ return;
286
304
  }
287
- }
305
+ seenIds.add(c.id);
306
+ if (mode === "text") {
307
+ const label = getContent(c.labelHtml);
308
+ if (!label) choicesErrors[key] = "Content should not be empty.";
309
+ else if (seenLabels.has(label)) choicesErrors[key] = "Content should be unique.";
310
+ seenLabels.add(label);
311
+ } else if (!c.imageUrl?.trim()) choicesErrors[key] = "An image is required.";
312
+ else if (!c.imageAlt?.trim()) choicesErrors[key] = "Image alt text is required.";
313
+ });
314
+ if (Object.keys(choicesErrors).length) errors.choices = choicesErrors;
288
315
  const correct = question.correctChoiceId;
289
- if (!correct || !choices.some((c) => c.id === correct)) errors.correctChoiceId = "Correct choice must match one of the choice ids";
316
+ if (!correct) errors.correctResponse = "No correct response defined.";
317
+ else if (!choices.some((c) => c?.id === correct)) errors.correctResponse = "The correct response must be one of the choices.";
290
318
  if (question.hasAudio) {
291
319
  if (!!!question.audioUrl?.trim()) errors.audioUrl = "A playable audio URL is required when audio is enabled";
292
320
  }
@@ -313,7 +341,6 @@ var validate = (question = {}, _config = {}) => {
313
341
  "audioBlankTemplateMarginTopRem",
314
342
  "audioBlankTemplateMarginBottomRem",
315
343
  "audioInstructionsMaxWidthPx",
316
- "narrowHorizontalChoiceMaxWidthPx",
317
344
  "stimulusGridColumnGapRem",
318
345
  "stimulusGridRowGapRem",
319
346
  "stimulusSentenceMarginTopRem",