Skip to content

Commit 2cf0fc6

Browse files
committed
wrap up i18n
1 parent 9009ad1 commit 2cf0fc6

61 files changed

Lines changed: 11827 additions & 155 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pie-to-qti2/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
".": {
1010
"types": "./dist/index.d.ts",
1111
"import": "./dist/index.js"
12+
},
13+
"./generators/manifest-generator": {
14+
"types": "./dist/generators/manifest-generator.d.ts",
15+
"import": "./dist/generators/manifest-generator.js"
1216
}
1317
},
1418
"scripts": {

packages/pie-to-qti2/src/generators/manifest-generator.ts

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77

88
import { v4 as uuid } from 'uuid';
99
import type {
10+
AssessmentResource,
1011
ImsManifest,
12+
ItemResource,
1113
ManifestGenerationOptions,
1214
ManifestInput,
15+
PassageResource,
1316
Resource,
1417
ResourceDependency,
1518
} from '../types/manifest.js';
@@ -41,11 +44,16 @@ export function buildManifest(input: ManifestInput): ImsManifest {
4144
// Add passage resources first (items depend on them)
4245
if (input.passages && input.passages.length > 0) {
4346
for (const passage of input.passages) {
47+
// Add language metadata if locale is specified
48+
const metadata = passage.locale
49+
? { ...passage.metadata, language: passage.locale }
50+
: passage.metadata;
51+
4452
resources.push({
4553
identifier: passage.id,
4654
type: passage.type || 'imsqti_item_xmlv2p2',
4755
href: passage.filePath,
48-
metadata: passage.metadata,
56+
metadata,
4957
files: passage.files?.map(f => ({ href: f })),
5058
});
5159
}
@@ -62,11 +70,16 @@ export function buildManifest(input: ManifestInput): ImsManifest {
6270
}
6371
}
6472

73+
// Add language metadata if locale is specified
74+
const metadata = item.locale
75+
? { ...item.metadata, language: item.locale }
76+
: item.metadata;
77+
6578
resources.push({
6679
identifier: item.id,
6780
type: item.type || 'imsqti_item_xmlv2p2',
6881
href: item.filePath,
69-
metadata: item.metadata,
82+
metadata,
7083
files: item.files?.map(f => ({ href: f })),
7184
dependencies: dependencies.length > 0 ? dependencies : undefined,
7285
});
@@ -84,11 +97,16 @@ export function buildManifest(input: ManifestInput): ImsManifest {
8497
}
8598
}
8699

100+
// Add language metadata if locale is specified
101+
const metadata = assessment.locale
102+
? { ...assessment.metadata, language: assessment.locale }
103+
: assessment.metadata;
104+
87105
resources.push({
88106
identifier: assessment.id,
89107
type: assessment.type || 'imsqti_assessment_xmlv2p2',
90108
href: assessment.filePath,
91-
metadata: assessment.metadata,
109+
metadata,
92110
files: assessment.files?.map(f => ({ href: f })),
93111
dependencies: dependencies.length > 0 ? dependencies : undefined,
94112
});
@@ -154,9 +172,23 @@ export function manifestToXml(manifest: ImsManifest): string {
154172
// Metadata (if provided)
155173
if (resource.metadata) {
156174
lines.push(' <metadata>');
175+
176+
// Special handling for IMS LOM language (must be in lom/general structure)
177+
if (resource.metadata.language) {
178+
lines.push(' <imsmd:lom>');
179+
lines.push(' <imsmd:general>');
180+
lines.push(' <imsmd:language>' + escapeXml(String(resource.metadata.language)) + '</imsmd:language>');
181+
lines.push(' </imsmd:general>');
182+
lines.push(' </imsmd:lom>');
183+
}
184+
185+
// Other metadata fields
157186
for (const [key, value] of Object.entries(resource.metadata)) {
158-
lines.push(` <imsmd:${key}>${escapeXml(String(value))}</imsmd:${key}>`);
187+
if (key !== 'language') {
188+
lines.push(` <imsmd:${key}>${escapeXml(String(value))}</imsmd:${key}>`);
189+
}
159190
}
191+
160192
lines.push(' </metadata>');
161193
}
162194

@@ -310,3 +342,114 @@ export function generateAssessmentManifest(
310342

311343
return generateManifest(input);
312344
}
345+
/**
346+
* Generate manifest for multilingual content package
347+
*
348+
* Convenience function that expands base identifiers into locale-suffixed resources.
349+
* This is purely a helper - you can achieve the same result by calling `generateManifest()`
350+
* directly with locale-suffixed identifiers. The locale system works generically with
351+
* any identifier pattern.
352+
*
353+
* Each generated resource:
354+
* - Gets a locale-suffixed identifier (e.g., "item.en-US", "item.es-ES")
355+
* - Automatically receives IMS LOM language metadata
356+
* - Works with the same fallback system as manually-created resources
357+
*
358+
* @param input Multilingual manifest input
359+
* @returns Manifest XML string
360+
*
361+
* @example
362+
* ```typescript
363+
* // Using convenience function
364+
* const manifest = generateMultilingualManifest({
365+
* baseItems: [{
366+
* baseId: "simple-choice",
367+
* locales: {
368+
* "en-US": { filePath: "items/simple-choice.en-US.xml" },
369+
* "es-ES": { filePath: "items/simple-choice.es-ES.xml" }
370+
* }
371+
* }]
372+
* });
373+
*
374+
* // Equivalent manual approach (no special multilingual mode)
375+
* const manifest = generateManifest({
376+
* items: [
377+
* { id: "simple-choice.en-US", filePath: "items/simple-choice.en-US.xml", locale: "en-US" },
378+
* { id: "simple-choice.es-ES", filePath: "items/simple-choice.es-ES.xml", locale: "es-ES" }
379+
* ]
380+
* });
381+
* ```
382+
*/
383+
export function generateMultilingualManifest(input: {
384+
baseItems: Array<{
385+
baseId: string;
386+
locales: Record<string, { filePath: string; dependencies?: string[] }>;
387+
}>;
388+
basePassages?: Array<{
389+
baseId: string;
390+
locales: Record<string, { filePath: string }>;
391+
}>;
392+
baseAssessments?: Array<{
393+
baseId: string;
394+
locales: Record<string, { filePath: string; dependencies?: string[] }>;
395+
}>;
396+
options?: ManifestGenerationOptions;
397+
}): string {
398+
const items: ItemResource[] = [];
399+
const passages: PassageResource[] = [];
400+
const assessments: AssessmentResource[] = [];
401+
402+
// Expand passages with locale variants
403+
if (input.basePassages) {
404+
for (const basePassage of input.basePassages) {
405+
for (const [locale, data] of Object.entries(basePassage.locales)) {
406+
passages.push({
407+
id: `${basePassage.baseId}.${locale}`,
408+
filePath: data.filePath,
409+
locale,
410+
});
411+
}
412+
}
413+
}
414+
415+
// Expand items with locale variants
416+
for (const baseItem of input.baseItems) {
417+
for (const [locale, data] of Object.entries(baseItem.locales)) {
418+
// Map dependencies to locale-specific identifiers if they exist
419+
const dependencies = data.dependencies?.map(depId => `${depId}.${locale}`);
420+
421+
items.push({
422+
id: `${baseItem.baseId}.${locale}`,
423+
filePath: data.filePath,
424+
dependencies,
425+
locale,
426+
});
427+
}
428+
}
429+
430+
// Expand assessments with locale variants
431+
if (input.baseAssessments) {
432+
for (const baseAssessment of input.baseAssessments) {
433+
for (const [locale, data] of Object.entries(baseAssessment.locales)) {
434+
// Map dependencies to locale-specific identifiers
435+
const dependencies = data.dependencies?.map(depId => `${depId}.${locale}`);
436+
437+
assessments.push({
438+
id: `${baseAssessment.baseId}.${locale}`,
439+
filePath: data.filePath,
440+
dependencies,
441+
locale,
442+
});
443+
}
444+
}
445+
}
446+
447+
const manifestInput: ManifestInput = {
448+
items,
449+
passages,
450+
assessments: assessments.length > 0 ? assessments : undefined,
451+
options: input.options,
452+
};
453+
454+
return generateManifest(manifestInput);
455+
}

packages/pie-to-qti2/src/types/manifest.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ export interface ItemResource {
180180

181181
/** Metadata */
182182
metadata?: Record<string, any>;
183+
184+
/** Locale for this specific variant (e.g., "en-US", "es-ES") */
185+
locale?: string;
183186
}
184187

185188
/**
@@ -200,6 +203,9 @@ export interface PassageResource {
200203

201204
/** Metadata */
202205
metadata?: Record<string, any>;
206+
207+
/** Locale for this specific variant (e.g., "en-US", "es-ES") */
208+
locale?: string;
203209
}
204210

205211
/**
@@ -223,4 +229,7 @@ export interface AssessmentResource {
223229

224230
/** Metadata */
225231
metadata?: Record<string, any>;
232+
233+
/** Locale for this specific variant (e.g., "en-US", "es-ES") */
234+
locale?: string;
226235
}

packages/qti2-assessment-player/src/components/ItemRenderer.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
33
import { registerDefaultComponents } from '@pie-qti/qti2-default-components';
4+
// @ts-expect-error - Svelte-check can't resolve workspace subpath exports, but runtime works correctly
45
import { ItemBody } from '@pie-qti/qti2-item-player/components';
56
import { Player, type QTIRole } from '@pie-qti/qti2-item-player';
67
import type { I18nProvider } from '@pie-qti/qti2-i18n';

packages/qti2-default-components/src/plugins/associate/AssociateInteraction.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@
158158
<div part="helper" class="qti-associate-helper alert alert-info">
159159
<span class="text-sm">
160160
{#if selectedForPairing}
161-
Click another item to create an association (or click again to deselect)
161+
{i18n?.t('interactions.associate.clickAnotherOrDeselect') ?? 'Click another item to create an association (or click again to deselect)'}
162162
{:else}
163-
Click two items to create an association between them
163+
{i18n?.t('interactions.associate.clickToAssociate') ?? 'Click two items to create an association between them'}
164164
{/if}
165165
</span>
166166
</div>

packages/qti2-default-components/src/plugins/gap-match/GapMatchInteraction.svelte

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
// Rendered prompt container - we keep the original HTML structure and insert live gap targets.
3030
let promptContainer: HTMLDivElement | undefined = $state();
3131
32+
// Track cleanup functions for event listeners to prevent memory leaks
33+
let cleanupFunctions: (() => void)[] = [];
34+
3235
function handleGapChange(gapId: string, wordId: string) {
3336
// Remove any existing pair for this gap
3437
const newPairs = pairs.filter((p: string) => !p.endsWith(` ${gapId}`));
@@ -86,6 +89,10 @@
8689
function renderPromptWithGaps() {
8790
if (!promptContainer || !parsedInteraction?.promptText) return;
8891
92+
// Clean up any existing event listeners before creating new ones
93+
cleanupFunctions.forEach(cleanup => cleanup());
94+
cleanupFunctions = [];
95+
8996
// Replace placeholders with marker spans so we can keep the original HTML structure.
9097
const html = parsedInteraction.promptText.replace(/\[GAP:([^\]]+)\]/g, (_m, gapId) => {
9198
const safe = String(gapId).replace(/"/g, '&quot;');
@@ -113,47 +120,64 @@
113120
114121
const selected = getSelectedWord(gapId);
115122
if (selected) {
116-
btn.textContent = getWordText(selected);
123+
const word = getWordText(selected);
124+
btn.textContent = word;
117125
btn.removeAttribute('data-empty');
118-
btn.setAttribute('aria-label', `Blank ${gapId}, filled with ${getWordText(selected)}. Click to clear.`);
126+
const filledAriaLabel = i18n?.t('interactions.gapMatch.filledGapAriaLabel', { gapId, word }) ?? `Blank ${gapId}, filled with ${word}. Click to clear.`;
127+
btn.setAttribute('aria-label', filledAriaLabel);
119128
} else {
120129
// Keep the gap visually blank (no letters), but still accessible.
121130
btn.textContent = '';
122131
btn.setAttribute('data-empty', 'true');
123-
btn.setAttribute('aria-label', `Blank ${gapId}. Drop an answer here.`);
132+
const ariaLabel = i18n?.t('interactions.gapMatch.blankGapAriaLabel', { gapId }) ?? `Blank ${gapId}. Drop an answer here.`;
133+
btn.setAttribute('aria-label', ariaLabel);
124134
}
125135
if (disabled) btn.setAttribute('aria-disabled', 'true');
126136
127-
btn.addEventListener('dragenter', (e) => {
137+
const onDragEnter = (e: DragEvent) => {
128138
if (disabled) return;
129139
e.preventDefault();
130140
btn.classList.add('is-dragover');
131-
});
141+
};
132142
133-
btn.addEventListener('dragover', (e) => {
143+
const onDragOver = (e: DragEvent) => {
134144
if (disabled) return;
135145
e.preventDefault();
136146
btn.classList.add('is-dragover');
137-
});
147+
};
138148
139-
btn.addEventListener('dragleave', () => {
149+
const onDragLeave = () => {
140150
btn.classList.remove('is-dragover');
141-
});
151+
};
142152
143-
btn.addEventListener('drop', (e) => {
153+
const onDrop = (e: DragEvent) => {
144154
if (disabled) return;
145155
e.preventDefault();
146156
btn.classList.remove('is-dragover');
147157
const wordId = e.dataTransfer?.getData('text/plain') ?? '';
148158
if (!wordId) return;
149159
handleGapChange(gapId, wordId);
150-
});
160+
};
151161
152-
// Click-to-clear for usability (and touch devices).
153-
btn.addEventListener('click', () => {
162+
const onClick = () => {
154163
if (disabled) return;
155164
const current = getSelectedWord(gapId);
156165
if (current) handleGapChange(gapId, '');
166+
};
167+
168+
btn.addEventListener('dragenter', onDragEnter);
169+
btn.addEventListener('dragover', onDragOver);
170+
btn.addEventListener('dragleave', onDragLeave);
171+
btn.addEventListener('drop', onDrop);
172+
btn.addEventListener('click', onClick);
173+
174+
// Store cleanup functions to remove event listeners later
175+
cleanupFunctions.push(() => {
176+
btn.removeEventListener('dragenter', onDragEnter);
177+
btn.removeEventListener('dragover', onDragOver);
178+
btn.removeEventListener('dragleave', onDragLeave);
179+
btn.removeEventListener('drop', onDrop);
180+
btn.removeEventListener('click', onClick);
157181
});
158182
159183
ph.replaceWith(btn);

0 commit comments

Comments
 (0)