@longsightgroup/qti3-player 0.4.0 → 0.5.1

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 (91) hide show
  1. package/README.md +146 -13
  2. package/dist/index.d.ts +13 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +6 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/player-adapter.d.ts +62 -0
  7. package/dist/player-adapter.d.ts.map +1 -0
  8. package/dist/player-adapter.js +119 -0
  9. package/dist/player-adapter.js.map +1 -0
  10. package/dist/player-dev.d.ts +4 -0
  11. package/dist/player-dev.d.ts.map +1 -0
  12. package/dist/player-dev.js +14 -0
  13. package/dist/player-dev.js.map +1 -0
  14. package/dist/player-element.d.ts +14 -1
  15. package/dist/player-element.d.ts.map +1 -1
  16. package/dist/player-element.js +57 -5
  17. package/dist/player-element.js.map +1 -1
  18. package/dist/player-locale.d.ts +8 -3
  19. package/dist/player-locale.d.ts.map +1 -1
  20. package/dist/player-locale.js +16 -175
  21. package/dist/player-locale.js.map +1 -1
  22. package/dist/player-message-catalog-default.d.ts +4 -0
  23. package/dist/player-message-catalog-default.d.ts.map +1 -0
  24. package/dist/player-message-catalog-default.js +118 -0
  25. package/dist/player-message-catalog-default.js.map +1 -0
  26. package/dist/player-message-catalog-validate.d.ts +31 -0
  27. package/dist/player-message-catalog-validate.d.ts.map +1 -0
  28. package/dist/player-message-catalog-validate.js +327 -0
  29. package/dist/player-message-catalog-validate.js.map +1 -0
  30. package/dist/player-message-catalog.d.ts +18 -0
  31. package/dist/player-message-catalog.d.ts.map +1 -0
  32. package/dist/player-message-catalog.js +40 -0
  33. package/dist/player-message-catalog.js.map +1 -0
  34. package/dist/player-message-keys.d.ts +6 -0
  35. package/dist/player-message-keys.d.ts.map +1 -0
  36. package/dist/player-message-keys.js +7 -0
  37. package/dist/player-message-keys.js.map +1 -0
  38. package/dist/player-message-manifest.d.ts +272 -0
  39. package/dist/player-message-manifest.d.ts.map +1 -0
  40. package/dist/player-message-manifest.js +83 -0
  41. package/dist/player-message-manifest.js.map +1 -0
  42. package/dist/player-message-overrides.d.ts +3 -0
  43. package/dist/player-message-overrides.d.ts.map +1 -0
  44. package/dist/player-message-overrides.js +28 -0
  45. package/dist/player-message-overrides.js.map +1 -0
  46. package/dist/player-message-resolver.d.ts +31 -0
  47. package/dist/player-message-resolver.d.ts.map +1 -0
  48. package/dist/player-message-resolver.js +110 -0
  49. package/dist/player-message-resolver.js.map +1 -0
  50. package/dist/player-messages.d.ts +0 -38
  51. package/dist/player-messages.d.ts.map +1 -1
  52. package/dist/player-types.d.ts +12 -2
  53. package/dist/player-types.d.ts.map +1 -1
  54. package/package.json +3 -3
  55. package/src/controls/remove-button.ts +8 -5
  56. package/src/index.ts +61 -5
  57. package/src/interactions/choice-interaction.ts +6 -2
  58. package/src/interactions/drawing-interaction.ts +14 -9
  59. package/src/interactions/end-attempt-interaction.ts +3 -3
  60. package/src/interactions/gap-match-interaction.ts +32 -13
  61. package/src/interactions/graphic-associate-interaction.ts +15 -10
  62. package/src/interactions/hotspot-interaction.ts +10 -6
  63. package/src/interactions/inline-choice-interaction.ts +4 -4
  64. package/src/interactions/interaction-registry.ts +12 -12
  65. package/src/interactions/match-interaction.ts +9 -6
  66. package/src/interactions/pair-interaction.ts +22 -14
  67. package/src/interactions/position-object-interaction.ts +22 -13
  68. package/src/interactions/select-point-interaction.ts +25 -13
  69. package/src/interactions/shared.ts +21 -4
  70. package/src/interactions/text-interaction.ts +14 -4
  71. package/src/interactions/upload-interaction.ts +6 -3
  72. package/src/player/interaction-render.ts +4 -4
  73. package/src/player-adapter.ts +253 -0
  74. package/src/player-dev.ts +14 -0
  75. package/src/player-element.ts +78 -8
  76. package/src/player-locale.ts +28 -199
  77. package/src/player-message-catalog-default.ts +119 -0
  78. package/src/player-message-catalog-validate.ts +425 -0
  79. package/src/player-message-catalog.ts +72 -0
  80. package/src/player-message-keys.ts +12 -0
  81. package/src/player-message-manifest.ts +103 -0
  82. package/src/player-message-overrides.ts +38 -0
  83. package/src/player-message-resolver.ts +205 -0
  84. package/src/player-messages.ts +0 -30
  85. package/src/player-types.ts +15 -4
  86. package/src/reorder/a11y.ts +22 -7
  87. package/src/reorder/graphic-order-interaction.ts +23 -16
  88. package/src/reorder/list-controls.ts +8 -6
  89. package/src/reorder/order-interaction.ts +7 -5
  90. package/src/styles/base-styles.ts +20 -5
  91. package/src/styles/graphic-styles.ts +0 -6
@@ -0,0 +1,119 @@
1
+ import type { PlayerMessageCatalog } from "./player-message-catalog.js";
2
+
3
+ /** English player chrome catalog (JSON-serializable). Hosts copy this shape for locale files. */
4
+ export const defaultPlayerMessageCatalog: PlayerMessageCatalog = {
5
+ locale: "en",
6
+ directions: {
7
+ up: "up",
8
+ down: "down",
9
+ left: "left",
10
+ right: "right",
11
+ },
12
+ interactionTypes: {
13
+ associate: "Associate",
14
+ choice: "Choice",
15
+ drawing: "Drawing",
16
+ endAttempt: "End attempt",
17
+ extendedText: "Extended text",
18
+ gapMatch: "Gap match",
19
+ graphicAssociate: "Graphic associate",
20
+ graphicGapMatch: "Graphic gap match",
21
+ graphicOrder: "Graphic order",
22
+ hottext: "Hot text",
23
+ hotspot: "Hotspot",
24
+ inlineChoice: "Inline choice",
25
+ match: "Match",
26
+ media: "Media",
27
+ order: "Order",
28
+ pair: "Pair",
29
+ portableCustom: "Portable custom",
30
+ positionObject: "Position object",
31
+ selectPoint: "Select point",
32
+ slider: "Slider",
33
+ textEntry: "Text entry",
34
+ upload: "Upload",
35
+ },
36
+ strings: {
37
+ remove: "Remove",
38
+ removePair: "Remove {label}",
39
+ clearDrawing: "Clear drawing",
40
+ clearPoints: "Clear points",
41
+ endAttempt: "End attempt",
42
+ uploadResponse: "Upload response",
43
+ movableObject: "Movable object",
44
+ placeObject: "Place",
45
+ inlineChoicePrompt: "Choose...",
46
+ extendedTextCounter: "{characters} {characterUnit}, {words} {wordUnit}",
47
+ "characterUnit.one": "character",
48
+ "characterUnit.other": "characters",
49
+ "wordUnit.one": "word",
50
+ "wordUnit.other": "words",
51
+ hotspotSelectionSummary: "Selected {selection}",
52
+ noPointSelected: "No point selected",
53
+ noRegionSelected: "No region selected",
54
+ noAssociationsMade: "No associations made",
55
+ "associationsMade.one": "{count} association made.",
56
+ "associationsMade.other": "{count} associations made.",
57
+ associationPairLabel: "{source} to {target}",
58
+ hotspotSelectedChooseAnother: "{label} selected. Choose another hotspot.",
59
+ moveChoice: "Move {label} {direction}",
60
+ movePoint: "Move point {direction}",
61
+ moveObject: "Move object {direction}",
62
+ matchSourcesBank: "Match sources",
63
+ matchTargetsBank: "Match targets",
64
+ matchSelectedPairsList: "Match selected pairs",
65
+ interactionSourcesBank: "{typeName} sources",
66
+ interactionTargetsBank: "{typeName} targets",
67
+ interactionSelectedPairsList: "{typeName} selected pairs",
68
+ associateFirstConceptRegion: "First concept",
69
+ associatePairWithRegion: "Pair with",
70
+ matchPromptRegion: "Prompt",
71
+ matchMatchRegion: "Match",
72
+ genericSourceRegion: "Source",
73
+ genericTargetRegion: "Target",
74
+ interactionHotspots: "{typeName} hotspots",
75
+ interactionImageAlt: "{typeName} image",
76
+ interactionCurrentOrderList: "{typeName} current order",
77
+ interactionSelectedOrderList: "{typeName} selected order",
78
+ interactionChoicesBank: "{typeName} choices",
79
+ interactionGapTargets: "{typeName} targets",
80
+ interactionTargetImage: "{typeName} target image",
81
+ interactionOptionsList: "{typeName} options",
82
+ interactionCoordinateResponse: "{typeName} coordinate response",
83
+ interactionCoordinateArea: "{typeName} coordinate area",
84
+ interactionCoordinateAreaSelected: "{typeName} coordinate area, selected {coordinates}",
85
+ interactionPlacementResponse: "{typeName} object placement response",
86
+ interactionPlacementStage: "{typeName} placement stage",
87
+ interactionPlacementStageEmpty: "{typeName} placement stage, object not placed",
88
+ interactionPlacementStageAt: "{typeName} placement stage, object at {coordinates}",
89
+ interactionDrawingResponse: "{typeName} response",
90
+ drawingSurface: "Drawing response surface",
91
+ drawingSurfaceEmpty: "Drawing response surface, no strokes",
92
+ "drawingSurfaceStrokeCount.one": "Drawing response surface, {count} stroke",
93
+ "drawingSurfaceStrokeCount.other": "Drawing response surface, {count} strokes",
94
+ drawingStatusEmpty: "No drawing strokes.",
95
+ "drawingStatusStrokeCount.one": "{count} drawing stroke.",
96
+ "drawingStatusStrokeCount.other": "{count} drawing strokes.",
97
+ gapLabel: "Gap {index}",
98
+ graphicGapTargetLabel: "Target {index}",
99
+ gapEmptyState: "{label}, empty",
100
+ gapAssignedState: "{label}, assigned {assigned}",
101
+ "gapLabelsPlacedCount.one": "{count} label placed.",
102
+ "gapLabelsPlacedCount.other": "{count} labels placed.",
103
+ gapNoLabelsPlaced: "No labels placed.",
104
+ orderedItemAtPosition: "{label}, position {position} of {total}",
105
+ orderedItemMovedOneStep: "{label} moved {direction}.",
106
+ orderedItemMovedToPosition: "{label} moved to position {position} of {total}.",
107
+ "graphicOrderRegionsSelected.one": "{count} region ordered.",
108
+ "graphicOrderRegionsSelected.other": "{count} regions ordered.",
109
+ graphicOrderNoRegionsSelected: "No regions ordered.",
110
+ objectNotPlaced: "Object not placed",
111
+ objectPositionedAt: "Object positioned at {coordinates}",
112
+ selectedPointAt: "Selected point {coordinates}",
113
+ "selectedPointsSummary.one": "{count} selected point: {coordinates}",
114
+ "selectedPointsSummary.other": "{count} selected points: {coordinates}",
115
+ extendedTextResponseLabel: "Extended text response",
116
+ textResponseLabel: "Text response",
117
+ sliderResponseLabel: "Slider response",
118
+ },
119
+ };
@@ -0,0 +1,425 @@
1
+ import { defaultPlayerMessageCatalog } from "./player-message-catalog-default.js";
2
+ import { extractMessagePlaceholders } from "./player-message-catalog.js";
3
+ import {
4
+ PLAYER_MESSAGE_MANIFEST,
5
+ type PlayerMessageKey,
6
+ type PlayerMessageManifestEntry,
7
+ } from "./player-message-manifest.js";
8
+ import type { QtiPlayerMovementDirection } from "./player-messages.js";
9
+
10
+ export type PlayerMessageCatalogDiagnosticCode =
11
+ | "invalid-catalog-root"
12
+ | "invalid-catalog-field"
13
+ | "invalid-strings-field"
14
+ | "invalid-string-value"
15
+ | "invalid-interaction-types-field"
16
+ | "invalid-interaction-type-value"
17
+ | "invalid-directions-field"
18
+ | "invalid-direction-value"
19
+ | "unknown-string-key"
20
+ | "missing-message-key"
21
+ | "missing-plural-form"
22
+ | "unknown-placeholder"
23
+ | "missing-placeholder"
24
+ | "empty-template"
25
+ | "invalid-direction-key";
26
+
27
+ export interface PlayerMessageCatalogDiagnostic {
28
+ code: PlayerMessageCatalogDiagnosticCode;
29
+ message: string;
30
+ key: string;
31
+ severity: "error" | "warning";
32
+ }
33
+
34
+ export interface ValidatePlayerMessageCatalogOptions {
35
+ /** When true, every manifest message id must be present in `catalog.strings` (or plural forms). */
36
+ requireAllKeys?: boolean;
37
+ }
38
+
39
+ export interface PlayerMessageCatalogValidationResult {
40
+ valid: boolean;
41
+ diagnostics: PlayerMessageCatalogDiagnostic[];
42
+ }
43
+
44
+ const MANIFEST_KEYS = new Set(PLAYER_MESSAGE_MANIFEST.map((entry) => entry.key));
45
+ const MANIFEST_BY_KEY = new Map(PLAYER_MESSAGE_MANIFEST.map((entry) => [entry.key, entry]));
46
+
47
+ const AUXILIARY_STRING_KEYS = new Set([
48
+ "characterUnit.one",
49
+ "characterUnit.other",
50
+ "wordUnit.one",
51
+ "wordUnit.other",
52
+ ]);
53
+
54
+ const VALID_DIRECTION_KEYS = new Set<QtiPlayerMovementDirection>(["up", "down", "left", "right"]);
55
+
56
+ type NormalizedCatalog = {
57
+ strings: Record<string, string>;
58
+ locale?: string;
59
+ interactionTypes: Record<string, string>;
60
+ directions: Partial<Record<QtiPlayerMovementDirection, string>>;
61
+ };
62
+
63
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ function isNonEmptyString(value: unknown): value is string {
68
+ return typeof value === "string";
69
+ }
70
+
71
+ function pushDiagnostic(
72
+ diagnostics: PlayerMessageCatalogDiagnostic[],
73
+ diagnostic: PlayerMessageCatalogDiagnostic,
74
+ ): void {
75
+ diagnostics.push(diagnostic);
76
+ }
77
+
78
+ function isManifestStringKey(key: string): boolean {
79
+ if (MANIFEST_KEYS.has(key as PlayerMessageKey)) return true;
80
+ if (AUXILIARY_STRING_KEYS.has(key)) return true;
81
+ const pluralMatch = /^(.+)\.(one|other)$/.exec(key);
82
+ if (pluralMatch && MANIFEST_KEYS.has(pluralMatch[1] as PlayerMessageKey)) {
83
+ return MANIFEST_BY_KEY.get(pluralMatch[1] as PlayerMessageKey)?.resolver === "plural";
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /** Placeholders a host may use for a manifest entry (superset of manifest params). */
89
+ export function allowedCatalogPlaceholders(entry: PlayerMessageManifestEntry): readonly string[] {
90
+ switch (entry.resolver) {
91
+ case "plain":
92
+ case "typeLabel":
93
+ return [];
94
+ case "template":
95
+ case "plural":
96
+ case "directionTemplate":
97
+ return entry.params ?? [];
98
+ case "typeTemplate":
99
+ return [...(entry.params?.filter((name) => name !== "type") ?? []), "typeName"];
100
+ case "extendedTextCounter":
101
+ return ["characters", "words", "characterUnit", "wordUnit"];
102
+ default:
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Placeholders a host template must include for a catalog string key.
109
+ * Uses the English default for that key when present; otherwise no placeholders are required.
110
+ * Manifest `params` bound required fields for resolvers that inject extra values at runtime
111
+ * (e.g. `extendedTextCounter` supplies `{characterUnit}` / `{wordUnit}` even when omitted).
112
+ */
113
+ export function requiredCatalogPlaceholders(
114
+ catalogKey: string,
115
+ entry?: PlayerMessageManifestEntry,
116
+ ): readonly string[] {
117
+ const manifestEntry = entry ?? manifestEntryForStringKey(catalogKey);
118
+ if (!manifestEntry) {
119
+ return [];
120
+ }
121
+
122
+ const allowed = new Set(allowedCatalogPlaceholders(manifestEntry));
123
+ const defaultTemplate = defaultPlayerMessageCatalog.strings[catalogKey];
124
+ const fromDefault = defaultTemplate
125
+ ? extractMessagePlaceholders(defaultTemplate).filter((name) => allowed.has(name))
126
+ : [];
127
+
128
+ if (manifestEntry.resolver === "extendedTextCounter") {
129
+ return (manifestEntry.params ?? []).filter((name) => allowed.has(name));
130
+ }
131
+
132
+ if (fromDefault.length > 0) {
133
+ return fromDefault;
134
+ }
135
+
136
+ return [];
137
+ }
138
+
139
+ function validateTemplate(
140
+ key: string,
141
+ template: string,
142
+ entry: PlayerMessageManifestEntry,
143
+ diagnostics: PlayerMessageCatalogDiagnostic[],
144
+ ): void {
145
+ if (!template.trim()) {
146
+ pushDiagnostic(diagnostics, {
147
+ code: "empty-template",
148
+ key,
149
+ severity: "error",
150
+ message: `Message "${key}" is empty.`,
151
+ });
152
+ return;
153
+ }
154
+
155
+ const allowed = new Set(allowedCatalogPlaceholders(entry));
156
+ const actual = extractMessagePlaceholders(template);
157
+ const required = requiredCatalogPlaceholders(key, entry);
158
+
159
+ for (const placeholder of actual) {
160
+ if (!allowed.has(placeholder)) {
161
+ pushDiagnostic(diagnostics, {
162
+ code: "unknown-placeholder",
163
+ key,
164
+ severity: "error",
165
+ message: `Message "${key}" uses unknown placeholder "{${placeholder}}". Allowed: ${[...allowed].join(", ") || "(none)"}.`,
166
+ });
167
+ }
168
+ }
169
+
170
+ for (const placeholder of required) {
171
+ if (!actual.includes(placeholder)) {
172
+ pushDiagnostic(diagnostics, {
173
+ code: "missing-placeholder",
174
+ key,
175
+ severity: "error",
176
+ message: `Message "${key}" is missing required placeholder "{${placeholder}}" (required by English default for this key).`,
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ function manifestEntryForStringKey(key: string): PlayerMessageManifestEntry | undefined {
183
+ const pluralMatch = /^(.+)\.(one|other)$/.exec(key);
184
+ const baseKey = (pluralMatch?.[1] ?? key) as PlayerMessageKey;
185
+ return MANIFEST_BY_KEY.get(baseKey);
186
+ }
187
+
188
+ function normalizeCatalogInput(
189
+ input: unknown,
190
+ diagnostics: PlayerMessageCatalogDiagnostic[],
191
+ ): NormalizedCatalog | null {
192
+ if (!isPlainObject(input)) {
193
+ pushDiagnostic(diagnostics, {
194
+ code: "invalid-catalog-root",
195
+ key: "(root)",
196
+ severity: "error",
197
+ message: "Player message catalog must be a JSON object.",
198
+ });
199
+ return null;
200
+ }
201
+
202
+ const normalized: NormalizedCatalog = {
203
+ strings: {},
204
+ interactionTypes: {},
205
+ directions: {},
206
+ };
207
+
208
+ if ("locale" in input) {
209
+ if (input.locale === undefined) {
210
+ // omit
211
+ } else if (!isNonEmptyString(input.locale)) {
212
+ pushDiagnostic(diagnostics, {
213
+ code: "invalid-catalog-field",
214
+ key: "locale",
215
+ severity: "error",
216
+ message: "Catalog locale must be a string when provided.",
217
+ });
218
+ } else {
219
+ normalized.locale = input.locale;
220
+ }
221
+ }
222
+
223
+ if (!("strings" in input) || input.strings === undefined) {
224
+ pushDiagnostic(diagnostics, {
225
+ code: "invalid-strings-field",
226
+ key: "strings",
227
+ severity: "error",
228
+ message: 'Catalog must include a "strings" object.',
229
+ });
230
+ } else if (!isPlainObject(input.strings)) {
231
+ pushDiagnostic(diagnostics, {
232
+ code: "invalid-strings-field",
233
+ key: "strings",
234
+ severity: "error",
235
+ message: 'Catalog "strings" must be an object mapping message ids to template strings.',
236
+ });
237
+ } else {
238
+ for (const [key, value] of Object.entries(input.strings)) {
239
+ if (!isNonEmptyString(value)) {
240
+ pushDiagnostic(diagnostics, {
241
+ code: "invalid-string-value",
242
+ key,
243
+ severity: "error",
244
+ message: `Catalog strings["${key}"] must be a string.`,
245
+ });
246
+ continue;
247
+ }
248
+ normalized.strings[key] = value;
249
+ }
250
+ }
251
+
252
+ if ("interactionTypes" in input && input.interactionTypes !== undefined) {
253
+ if (!isPlainObject(input.interactionTypes)) {
254
+ pushDiagnostic(diagnostics, {
255
+ code: "invalid-interaction-types-field",
256
+ key: "interactionTypes",
257
+ severity: "error",
258
+ message:
259
+ 'Catalog "interactionTypes" must be an object mapping interaction type ids to labels.',
260
+ });
261
+ } else {
262
+ for (const [typeId, label] of Object.entries(input.interactionTypes)) {
263
+ if (!isNonEmptyString(label)) {
264
+ pushDiagnostic(diagnostics, {
265
+ code: "invalid-interaction-type-value",
266
+ key: typeId,
267
+ severity: "error",
268
+ message: `Catalog interactionTypes["${typeId}"] must be a string.`,
269
+ });
270
+ continue;
271
+ }
272
+ normalized.interactionTypes[typeId] = label;
273
+ }
274
+ }
275
+ }
276
+
277
+ if ("directions" in input && input.directions !== undefined) {
278
+ if (!isPlainObject(input.directions)) {
279
+ pushDiagnostic(diagnostics, {
280
+ code: "invalid-directions-field",
281
+ key: "directions",
282
+ severity: "error",
283
+ message: 'Catalog "directions" must be an object mapping direction ids to labels.',
284
+ });
285
+ } else {
286
+ for (const [direction, label] of Object.entries(input.directions)) {
287
+ if (!VALID_DIRECTION_KEYS.has(direction as QtiPlayerMovementDirection)) {
288
+ pushDiagnostic(diagnostics, {
289
+ code: "invalid-direction-key",
290
+ key: direction,
291
+ severity: "error",
292
+ message: `Unknown direction key "${direction}". Use up, down, left, or right.`,
293
+ });
294
+ continue;
295
+ }
296
+ if (!isNonEmptyString(label)) {
297
+ pushDiagnostic(diagnostics, {
298
+ code: "invalid-direction-value",
299
+ key: direction,
300
+ severity: "error",
301
+ message: `Catalog directions["${direction}"] must be a string.`,
302
+ });
303
+ continue;
304
+ }
305
+ normalized.directions[direction as QtiPlayerMovementDirection] = label;
306
+ }
307
+ }
308
+ }
309
+
310
+ for (const field of Object.keys(input)) {
311
+ if (
312
+ field === "locale" ||
313
+ field === "strings" ||
314
+ field === "interactionTypes" ||
315
+ field === "directions"
316
+ ) {
317
+ continue;
318
+ }
319
+ pushDiagnostic(diagnostics, {
320
+ code: "invalid-catalog-field",
321
+ key: field,
322
+ severity: "error",
323
+ message: `Unknown catalog field "${field}".`,
324
+ });
325
+ }
326
+
327
+ return normalized;
328
+ }
329
+
330
+ function validateNormalizedCatalog(
331
+ catalog: NormalizedCatalog,
332
+ options: ValidatePlayerMessageCatalogOptions,
333
+ diagnostics: PlayerMessageCatalogDiagnostic[],
334
+ ): void {
335
+ const strings = catalog.strings;
336
+
337
+ for (const key of Object.keys(strings)) {
338
+ if (!isManifestStringKey(key)) {
339
+ pushDiagnostic(diagnostics, {
340
+ code: "unknown-string-key",
341
+ key,
342
+ severity: "error",
343
+ message: `Unknown catalog string key "${key}". See PLAYER_MESSAGE_KEYS.`,
344
+ });
345
+ continue;
346
+ }
347
+
348
+ const entry = manifestEntryForStringKey(key);
349
+ const template = strings[key];
350
+ if (!entry || template === undefined) continue;
351
+
352
+ validateTemplate(key, template, entry, diagnostics);
353
+ }
354
+
355
+ if (options.requireAllKeys) {
356
+ for (const entry of PLAYER_MESSAGE_MANIFEST) {
357
+ const hasBase = strings[entry.key] !== undefined;
358
+ const hasPlural =
359
+ entry.resolver === "plural" &&
360
+ (strings[`${entry.key}.one`] !== undefined || strings[`${entry.key}.other`] !== undefined);
361
+ if (entry.resolver === "typeLabel") {
362
+ continue;
363
+ }
364
+ if (entry.resolver === "plural") {
365
+ if (!hasBase && !hasPlural) {
366
+ pushDiagnostic(diagnostics, {
367
+ code: "missing-message-key",
368
+ key: entry.key,
369
+ severity: "error",
370
+ message: `Missing message "${entry.key}" (or "${entry.key}.one" / "${entry.key}.other").`,
371
+ });
372
+ }
373
+ if (
374
+ !strings[`${entry.key}.one`] &&
375
+ defaultPlayerMessageCatalog.strings[`${entry.key}.one`]
376
+ ) {
377
+ pushDiagnostic(diagnostics, {
378
+ code: "missing-plural-form",
379
+ key: `${entry.key}.one`,
380
+ severity: "warning",
381
+ message: `Missing plural form "${entry.key}.one". English fallback will be used.`,
382
+ });
383
+ }
384
+ if (
385
+ !strings[`${entry.key}.other`] &&
386
+ defaultPlayerMessageCatalog.strings[`${entry.key}.other`]
387
+ ) {
388
+ pushDiagnostic(diagnostics, {
389
+ code: "missing-plural-form",
390
+ key: `${entry.key}.other`,
391
+ severity: "warning",
392
+ message: `Missing plural form "${entry.key}.other". English fallback will be used.`,
393
+ });
394
+ }
395
+ } else if (!hasBase) {
396
+ pushDiagnostic(diagnostics, {
397
+ code: "missing-message-key",
398
+ key: entry.key,
399
+ severity: "error",
400
+ message: `Missing message "${entry.key}".`,
401
+ });
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Validates host player chrome JSON against {@link PLAYER_MESSAGE_MANIFEST}.
409
+ * Accepts raw `JSON.parse` output (`unknown`); returns structured diagnostics instead of throwing.
410
+ */
411
+ export function validatePlayerMessageCatalog(
412
+ catalog: unknown,
413
+ options: ValidatePlayerMessageCatalogOptions = {},
414
+ ): PlayerMessageCatalogValidationResult {
415
+ const diagnostics: PlayerMessageCatalogDiagnostic[] = [];
416
+ const normalized = normalizeCatalogInput(catalog, diagnostics);
417
+ if (!normalized) {
418
+ return { valid: false, diagnostics };
419
+ }
420
+
421
+ validateNormalizedCatalog(normalized, options, diagnostics);
422
+
423
+ const errors = diagnostics.filter((item) => item.severity === "error");
424
+ return { valid: errors.length === 0, diagnostics };
425
+ }
@@ -0,0 +1,72 @@
1
+ import { playerDevWarningsEnabled, warnPlayerMessageOnce } from "./player-dev.js";
2
+ import type { QtiPlayerMovementDirection } from "./player-messages.js";
3
+
4
+ export { defaultPlayerMessageCatalog } from "./player-message-catalog-default.js";
5
+ export {
6
+ createPlayerMessageResolver,
7
+ defaultPlayerMessageResolver,
8
+ type PlayerMessageOverride,
9
+ type PlayerMessageParams,
10
+ type PlayerMessageResolver,
11
+ type QtiPlayerMessageOverrides,
12
+ } from "./player-message-resolver.js";
13
+
14
+ /** JSON-serializable chrome strings for Harbor/LMS locale files. */
15
+ export interface PlayerMessageCatalog {
16
+ /** BCP 47 tag (metadata only). */
17
+ locale?: string;
18
+ /** Message templates; use `{name}` placeholders. Plural forms: `key.one` / `key.other`. */
19
+ strings: Record<string, string>;
20
+ /** QTI interaction type id → short label inserted as `{typeName}`. */
21
+ interactionTypes?: Record<string, string>;
22
+ /** Labels for moveChoice / movePoint / moveObject (`{direction}`). */
23
+ directions?: Partial<Record<QtiPlayerMovementDirection, string>>;
24
+ }
25
+
26
+ export function mergePlayerMessageCatalogs(
27
+ base: PlayerMessageCatalog,
28
+ partial: Partial<PlayerMessageCatalog>,
29
+ ): PlayerMessageCatalog {
30
+ const merged: PlayerMessageCatalog = {
31
+ strings: { ...base.strings, ...partial.strings },
32
+ };
33
+ const interactionTypes = { ...base.interactionTypes, ...partial.interactionTypes };
34
+ if (Object.keys(interactionTypes).length > 0) {
35
+ merged.interactionTypes = interactionTypes;
36
+ }
37
+ const directions = { ...base.directions, ...partial.directions };
38
+ if (Object.keys(directions).length > 0) {
39
+ merged.directions = directions;
40
+ }
41
+ const locale = partial.locale ?? base.locale;
42
+ if (locale !== undefined) {
43
+ merged.locale = locale;
44
+ }
45
+ return merged;
46
+ }
47
+
48
+ export function extractMessagePlaceholders(template: string): string[] {
49
+ const names = new Set<string>();
50
+ for (const match of template.matchAll(/\{(\w+)\}/g)) {
51
+ names.add(match[1] ?? "");
52
+ }
53
+ return [...names].filter(Boolean);
54
+ }
55
+
56
+ export function formatPlayerMessage(
57
+ template: string,
58
+ values: Record<string, string | number>,
59
+ ): string {
60
+ const placeholders = extractMessagePlaceholders(template);
61
+ const missing = placeholders.filter((name) => values[name] === undefined);
62
+ if (missing.length > 0 && playerDevWarningsEnabled()) {
63
+ warnPlayerMessageOnce(
64
+ `missing-placeholder-values:${missing.join(",")}`,
65
+ `Player message template is missing values for: ${missing.map((name) => `{${name}}`).join(", ")}.`,
66
+ );
67
+ }
68
+ return template.replace(/\{(\w+)\}/g, (_, key: string) => {
69
+ const value = values[key];
70
+ return value === undefined ? `{${key}}` : String(value);
71
+ });
72
+ }
@@ -0,0 +1,12 @@
1
+ import { defaultPlayerMessageCatalog } from "./player-message-catalog-default.js";
2
+ import { PLAYER_MESSAGE_MANIFEST, type PlayerMessageKey } from "./player-message-manifest.js";
3
+
4
+ /** Chrome message ids (from {@link PLAYER_MESSAGE_MANIFEST}). */
5
+ export const PLAYER_MESSAGE_KEYS = PLAYER_MESSAGE_MANIFEST.map(
6
+ (entry) => entry.key,
7
+ ) as readonly PlayerMessageKey[];
8
+
9
+ /** All `strings` keys in the English catalog (including plural/unit suffix keys). */
10
+ export const PLAYER_MESSAGE_STRING_KEYS = Object.keys(
11
+ defaultPlayerMessageCatalog.strings,
12
+ ) as ReadonlyArray<string>;