@pie-element/mc-populated-blank 0.2.13 → 0.3.0-next.10

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