@superbuilders/incept-renderer 0.1.0 → 0.1.2
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.
- package/README.md +632 -88
- package/dist/actions/index.d.ts +1 -1
- package/dist/actions/index.js +362 -552
- package/dist/actions/index.js.map +1 -1
- package/dist/components/index.d.ts +3 -3
- package/dist/components/index.js +71 -115
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +415 -461
- package/dist/index.js.map +1 -1
- package/dist/{schema-DxNEXGoq.d.ts → schema-CkAPLwco.d.ts} +5 -5
- package/dist/{types-MOyn9ktl.d.ts → types-DIDj-78l.d.ts} +1 -1
- package/package.json +16 -3
package/README.md
CHANGED
|
@@ -1,114 +1,461 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @superbuilders/incept-renderer
|
|
2
2
|
|
|
3
|
-
A QTI 3.0
|
|
3
|
+
A secure, server-driven QTI 3.0 assessment renderer for React/Next.js applications. Renders interactive questions with built-in validation while keeping correct answers secure on the server.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Table of Contents
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
7
|
+
- [Installation](#installation)
|
|
8
|
+
- [Core Concepts](#core-concepts)
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [Architecture](#architecture)
|
|
11
|
+
- [API Reference](#api-reference)
|
|
12
|
+
- [Server Functions](#server-functions)
|
|
13
|
+
- [Client Components](#client-components)
|
|
14
|
+
- [Types](#types)
|
|
15
|
+
- [Theming](#theming)
|
|
16
|
+
- [Complete Examples](#complete-examples)
|
|
17
|
+
- [Supported Interactions](#supported-interactions)
|
|
18
|
+
- [FAQ](#faq)
|
|
19
|
+
|
|
20
|
+
---
|
|
12
21
|
|
|
13
22
|
## Installation
|
|
14
23
|
|
|
15
24
|
```bash
|
|
16
|
-
npm install @
|
|
25
|
+
npm install @superbuilders/incept-renderer
|
|
26
|
+
# or
|
|
27
|
+
bun add @superbuilders/incept-renderer
|
|
17
28
|
# or
|
|
18
|
-
|
|
29
|
+
yarn add @superbuilders/incept-renderer
|
|
19
30
|
```
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
### Peer Dependencies
|
|
22
33
|
|
|
23
|
-
|
|
34
|
+
This package requires:
|
|
35
|
+
- `react` >= 18.0.0
|
|
36
|
+
- `react-dom` >= 18.0.0
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
For full functionality with Tailwind CSS theming, ensure your app has Tailwind configured.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Core Concepts
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
### Security Model
|
|
45
|
+
|
|
46
|
+
**Correct answers NEVER leave the server.** This package uses a secure architecture where:
|
|
47
|
+
|
|
48
|
+
1. **Server parses XML** → Extracts only display data (questions, choices, prompts)
|
|
49
|
+
2. **Client renders UI** → Users interact with questions
|
|
50
|
+
3. **Server validates** → Checks answers against the original XML
|
|
51
|
+
4. **Client shows feedback** → Displays correctness and feedback messages
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
55
|
+
│ XML Source (your database/API) │
|
|
56
|
+
│ Contains: questions, choices, AND correct answers │
|
|
57
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
58
|
+
│
|
|
59
|
+
▼
|
|
60
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
61
|
+
│ SERVER: buildDisplayModel(xml) │
|
|
62
|
+
│ Extracts: questions, choices, prompts │
|
|
63
|
+
│ EXCLUDES: correct answers, response processing rules │
|
|
64
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
65
|
+
│
|
|
66
|
+
▼
|
|
67
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
68
|
+
│ CLIENT: QTIRenderer │
|
|
69
|
+
│ Shows: questions, choices, user can select answers │
|
|
70
|
+
│ Cannot see: correct answers (never sent to browser) │
|
|
71
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
72
|
+
│
|
|
73
|
+
▼ User clicks "Check"
|
|
74
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
75
|
+
│ SERVER: validateResponsesSecure(xml, userResponses) │
|
|
76
|
+
│ Compares: user answers against correct answers │
|
|
77
|
+
│ Returns: isCorrect, feedback HTML, per-choice correctness │
|
|
78
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
79
|
+
│
|
|
80
|
+
▼
|
|
81
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
82
|
+
│ CLIENT: Shows feedback │
|
|
83
|
+
│ Green/red highlights, feedback messages, scores │
|
|
84
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
85
|
+
```
|
|
35
86
|
|
|
36
|
-
|
|
37
|
-
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Quick Start
|
|
90
|
+
|
|
91
|
+
### 1. Create the Server Component (page.tsx)
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// app/quiz/[id]/page.tsx
|
|
95
|
+
import * as React from "react"
|
|
96
|
+
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
|
|
97
|
+
import { QuizClient } from "./client"
|
|
98
|
+
|
|
99
|
+
export default function QuizPage({ params }: { params: Promise<{ id: string }> }) {
|
|
100
|
+
// Fetch your QTI XML and build the display model
|
|
101
|
+
const displayPromise = params.then(async (p) => {
|
|
102
|
+
const xml = await getQtiXmlFromYourDatabase(p.id)
|
|
103
|
+
return buildDisplayModel(xml)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Create a server action for validation
|
|
107
|
+
async function validateAnswers(responses: Record<string, string | string[]>) {
|
|
38
108
|
"use server"
|
|
39
|
-
|
|
109
|
+
const { id } = await params
|
|
110
|
+
const xml = await getQtiXmlFromYourDatabase(id)
|
|
111
|
+
return validateResponsesSecure(xml, responses)
|
|
40
112
|
}
|
|
41
113
|
|
|
42
|
-
return
|
|
114
|
+
return (
|
|
115
|
+
<React.Suspense fallback={<div>Loading question...</div>}>
|
|
116
|
+
<QuizClient displayPromise={displayPromise} onValidate={validateAnswers} />
|
|
117
|
+
</React.Suspense>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Your function to get QTI XML from wherever you store it
|
|
122
|
+
async function getQtiXmlFromYourDatabase(id: string): Promise<string> {
|
|
123
|
+
// Examples:
|
|
124
|
+
// - Database: await db.query.questions.findFirst({ where: eq(id, questionId) })
|
|
125
|
+
// - REST API: await fetch(`https://api.example.com/questions/${id}`).then(r => r.text())
|
|
126
|
+
// - File system: await fs.readFile(`./questions/${id}.xml`, 'utf-8')
|
|
127
|
+
throw new Error("Implement this function")
|
|
43
128
|
}
|
|
44
129
|
```
|
|
45
130
|
|
|
46
|
-
### 2.
|
|
131
|
+
### 2. Create the Client Component (client.tsx)
|
|
47
132
|
|
|
48
133
|
```tsx
|
|
49
134
|
// app/quiz/[id]/client.tsx
|
|
50
135
|
"use client"
|
|
51
136
|
|
|
52
|
-
import
|
|
53
|
-
import { QTIRenderer } from "@
|
|
54
|
-
import type {
|
|
137
|
+
import * as React from "react"
|
|
138
|
+
import { QTIRenderer } from "@superbuilders/incept-renderer"
|
|
139
|
+
import type { DisplayItem, FormShape, ValidateResult } from "@superbuilders/incept-renderer"
|
|
55
140
|
|
|
56
|
-
interface
|
|
57
|
-
item:
|
|
58
|
-
onValidate: (responses: Record<string, string[]>) => Promise<
|
|
141
|
+
interface QuizClientProps {
|
|
142
|
+
displayPromise: Promise<{ item: DisplayItem; shape: FormShape; itemKey: string }>
|
|
143
|
+
onValidate: (responses: Record<string, string | string[]>) => Promise<ValidateResult>
|
|
59
144
|
}
|
|
60
145
|
|
|
61
|
-
export function QuizClient({
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
146
|
+
export function QuizClient({ displayPromise, onValidate }: QuizClientProps) {
|
|
147
|
+
// Unwrap the promise from the server
|
|
148
|
+
const { item, shape, itemKey } = React.use(displayPromise)
|
|
149
|
+
|
|
150
|
+
// State management
|
|
151
|
+
const [responses, setResponses] = React.useState<Record<string, string | string[]>>({})
|
|
152
|
+
const [result, setResult] = React.useState<ValidateResult | undefined>()
|
|
153
|
+
const [showFeedback, setShowFeedback] = React.useState(false)
|
|
154
|
+
const [isChecking, setIsChecking] = React.useState(false)
|
|
65
155
|
|
|
156
|
+
// Handle user selecting/changing answers
|
|
157
|
+
const handleResponseChange = (responseId: string, value: string | string[]) => {
|
|
158
|
+
setResponses(prev => ({ ...prev, [responseId]: value }))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Handle "Check Answer" click
|
|
66
162
|
const handleCheck = async () => {
|
|
67
|
-
|
|
68
|
-
|
|
163
|
+
setIsChecking(true)
|
|
164
|
+
const validation = await onValidate(responses)
|
|
165
|
+
setResult(validation)
|
|
69
166
|
setShowFeedback(true)
|
|
167
|
+
setIsChecking(false)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Handle "Try Again" click
|
|
171
|
+
const handleTryAgain = () => {
|
|
172
|
+
setShowFeedback(false)
|
|
173
|
+
setResult(undefined)
|
|
70
174
|
}
|
|
71
175
|
|
|
72
176
|
return (
|
|
73
|
-
<div>
|
|
177
|
+
<div className="max-w-2xl mx-auto p-4">
|
|
178
|
+
{/* The QTI Renderer */}
|
|
74
179
|
<QTIRenderer
|
|
75
180
|
item={item}
|
|
76
|
-
theme="duolingo"
|
|
77
181
|
responses={responses}
|
|
78
|
-
onResponseChange={
|
|
79
|
-
setResponses(prev => ({ ...prev, [id]: values }))
|
|
80
|
-
}
|
|
182
|
+
onResponseChange={handleResponseChange}
|
|
81
183
|
showFeedback={showFeedback}
|
|
82
|
-
|
|
184
|
+
disabled={showFeedback}
|
|
185
|
+
overallFeedback={result ? {
|
|
186
|
+
isCorrect: result.overallCorrect,
|
|
187
|
+
messageHtml: result.feedbackHtml
|
|
188
|
+
} : undefined}
|
|
189
|
+
choiceCorrectness={result?.selectedChoicesByResponse}
|
|
190
|
+
responseFeedback={result?.perResponse}
|
|
83
191
|
/>
|
|
84
192
|
|
|
85
|
-
|
|
193
|
+
{/* Your app's buttons */}
|
|
194
|
+
<div className="mt-6 flex gap-4">
|
|
195
|
+
{!showFeedback ? (
|
|
196
|
+
<button
|
|
197
|
+
onClick={handleCheck}
|
|
198
|
+
disabled={isChecking || Object.keys(responses).length === 0}
|
|
199
|
+
className="bg-green-500 text-white px-6 py-3 rounded-lg font-bold disabled:opacity-50"
|
|
200
|
+
>
|
|
201
|
+
{isChecking ? "Checking..." : "Check Answer"}
|
|
202
|
+
</button>
|
|
203
|
+
) : result?.overallCorrect ? (
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => window.location.href = "/next-question"}
|
|
206
|
+
className="bg-green-500 text-white px-6 py-3 rounded-lg font-bold"
|
|
207
|
+
>
|
|
208
|
+
Continue →
|
|
209
|
+
</button>
|
|
210
|
+
) : (
|
|
211
|
+
<button
|
|
212
|
+
onClick={handleTryAgain}
|
|
213
|
+
className="bg-blue-500 text-white px-6 py-3 rounded-lg font-bold"
|
|
214
|
+
>
|
|
215
|
+
Try Again
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
86
219
|
</div>
|
|
87
220
|
)
|
|
88
221
|
}
|
|
89
222
|
```
|
|
90
223
|
|
|
91
|
-
|
|
224
|
+
---
|
|
92
225
|
|
|
93
|
-
|
|
226
|
+
## Architecture
|
|
227
|
+
|
|
228
|
+
### File Structure Pattern
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
app/quiz/[id]/
|
|
232
|
+
├── page.tsx # Server Component: fetches XML, creates validation action
|
|
233
|
+
└── client.tsx # Client Component: renders QTIRenderer, manages state
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Data Flow
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
1. User visits /quiz/123
|
|
240
|
+
│
|
|
241
|
+
▼
|
|
242
|
+
2. page.tsx (Server)
|
|
243
|
+
- Fetches QTI XML from your data source
|
|
244
|
+
- Calls buildDisplayModel(xml)
|
|
245
|
+
- Passes display data to client
|
|
246
|
+
│
|
|
247
|
+
▼
|
|
248
|
+
3. client.tsx (Client)
|
|
249
|
+
- Renders QTIRenderer with display data
|
|
250
|
+
- User interacts with choices
|
|
251
|
+
│
|
|
252
|
+
▼
|
|
253
|
+
4. User clicks "Check Answer"
|
|
254
|
+
- Client calls onValidate(responses)
|
|
255
|
+
- Server action validates against original XML
|
|
256
|
+
- Returns { overallCorrect, feedbackHtml, selectedChoicesByResponse }
|
|
257
|
+
│
|
|
258
|
+
▼
|
|
259
|
+
5. Client shows feedback
|
|
260
|
+
- QTIRenderer shows green/red highlights
|
|
261
|
+
- Feedback message displayed
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## API Reference
|
|
267
|
+
|
|
268
|
+
### Server Functions
|
|
269
|
+
|
|
270
|
+
Import from `@superbuilders/incept-renderer/actions`:
|
|
271
|
+
|
|
272
|
+
#### `buildDisplayModel(xml: string)`
|
|
273
|
+
|
|
274
|
+
Parses QTI XML and extracts display-safe data.
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
import { buildDisplayModel } from "@superbuilders/incept-renderer/actions"
|
|
278
|
+
|
|
279
|
+
const { item, shape, itemKey } = await buildDisplayModel(qtiXmlString)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Parameters:**
|
|
283
|
+
- `xml` (string) - Raw QTI 3.0 XML string
|
|
284
|
+
|
|
285
|
+
**Returns:**
|
|
286
|
+
```tsx
|
|
287
|
+
{
|
|
288
|
+
itemKey: string // Unique identifier for caching
|
|
289
|
+
item: DisplayItem // Parsed question data for rendering
|
|
290
|
+
shape: FormShape // Schema describing expected response structure
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
#### `validateResponsesSecure(xml: string, responses: Record<string, string | string[]>)`
|
|
295
|
+
|
|
296
|
+
Validates user responses against the QTI XML.
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import { validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
|
|
300
|
+
|
|
301
|
+
const result = await validateResponsesSecure(qtiXmlString, {
|
|
302
|
+
RESPONSE: "choice_A"
|
|
303
|
+
})
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Parameters:**
|
|
307
|
+
- `xml` (string) - Raw QTI 3.0 XML string (same as used for buildDisplayModel)
|
|
308
|
+
- `responses` (Record<string, string | string[]>) - User's selected answers
|
|
309
|
+
|
|
310
|
+
**Returns:**
|
|
311
|
+
```tsx
|
|
312
|
+
{
|
|
313
|
+
overallCorrect: boolean // true if all responses are correct
|
|
314
|
+
feedbackHtml: string // Combined HTML feedback message
|
|
315
|
+
selectedChoicesByResponse: Record<string, Array<{ id: string; isCorrect: boolean }>>
|
|
316
|
+
perResponse: Record<string, { isCorrect: boolean; messageHtml?: string }>
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
### Client Components
|
|
323
|
+
|
|
324
|
+
Import from `@superbuilders/incept-renderer`:
|
|
325
|
+
|
|
326
|
+
#### `<QTIRenderer />`
|
|
327
|
+
|
|
328
|
+
The main component for rendering QTI questions.
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
import { QTIRenderer } from "@superbuilders/incept-renderer"
|
|
332
|
+
|
|
333
|
+
<QTIRenderer
|
|
334
|
+
item={displayItem}
|
|
335
|
+
responses={responses}
|
|
336
|
+
onResponseChange={handleChange}
|
|
337
|
+
showFeedback={showFeedback}
|
|
338
|
+
disabled={isDisabled}
|
|
339
|
+
overallFeedback={feedbackData}
|
|
340
|
+
choiceCorrectness={correctnessMap}
|
|
341
|
+
responseFeedback={perResponseFeedback}
|
|
342
|
+
theme="duolingo"
|
|
343
|
+
/>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Props:**
|
|
347
|
+
|
|
348
|
+
| Prop | Type | Required | Description |
|
|
349
|
+
|------|------|----------|-------------|
|
|
350
|
+
| `item` | `DisplayItem` | ✅ | Parsed question data from `buildDisplayModel` |
|
|
351
|
+
| `responses` | `Record<string, string \| string[]>` | ✅ | Current user responses |
|
|
352
|
+
| `onResponseChange` | `(id: string, value: string \| string[]) => void` | ✅ | Called when user selects/changes an answer |
|
|
353
|
+
| `showFeedback` | `boolean` | ❌ | Whether to show correct/incorrect feedback |
|
|
354
|
+
| `disabled` | `boolean` | ❌ | Disable all interactions |
|
|
355
|
+
| `overallFeedback` | `{ isCorrect: boolean; messageHtml?: string }` | ❌ | Overall feedback after validation |
|
|
356
|
+
| `choiceCorrectness` | `Record<string, Array<{ id: string; isCorrect: boolean }>>` | ❌ | Per-choice correctness for highlighting |
|
|
357
|
+
| `responseFeedback` | `Record<string, { isCorrect: boolean; messageHtml?: string }>` | ❌ | Per-response feedback messages |
|
|
358
|
+
| `theme` | `"duolingo" \| "neobrutalist" \| string` | ❌ | Visual theme (default: `"duolingo"`) |
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### Types
|
|
363
|
+
|
|
364
|
+
Import from `@superbuilders/incept-renderer`:
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
import type {
|
|
368
|
+
DisplayItem,
|
|
369
|
+
DisplayBlock,
|
|
370
|
+
DisplayChoice,
|
|
371
|
+
DisplayChoiceInteraction,
|
|
372
|
+
FormShape,
|
|
373
|
+
ValidateResult
|
|
374
|
+
} from "@superbuilders/incept-renderer"
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### `DisplayItem`
|
|
378
|
+
|
|
379
|
+
```tsx
|
|
380
|
+
interface DisplayItem {
|
|
381
|
+
identifier: string
|
|
382
|
+
title: string
|
|
383
|
+
blocks: DisplayBlock[]
|
|
384
|
+
}
|
|
385
|
+
```
|
|
94
386
|
|
|
95
|
-
|
|
387
|
+
#### `DisplayBlock`
|
|
96
388
|
|
|
97
389
|
```tsx
|
|
98
|
-
|
|
99
|
-
|
|
390
|
+
type DisplayBlock =
|
|
391
|
+
| { type: "html"; html: string }
|
|
392
|
+
| { type: "interaction"; interaction: DisplayChoiceInteraction }
|
|
393
|
+
```
|
|
100
394
|
|
|
101
|
-
|
|
102
|
-
import "@alpha/qti-renderer/themes/neobrutalist.css"
|
|
395
|
+
#### `DisplayChoiceInteraction`
|
|
103
396
|
|
|
104
|
-
|
|
105
|
-
|
|
397
|
+
```tsx
|
|
398
|
+
interface DisplayChoiceInteraction {
|
|
399
|
+
responseIdentifier: string
|
|
400
|
+
type: "choice"
|
|
401
|
+
maxChoices: number
|
|
402
|
+
prompt?: string
|
|
403
|
+
choices: DisplayChoice[]
|
|
404
|
+
}
|
|
106
405
|
```
|
|
107
406
|
|
|
108
|
-
|
|
407
|
+
#### `DisplayChoice`
|
|
408
|
+
|
|
409
|
+
```tsx
|
|
410
|
+
interface DisplayChoice {
|
|
411
|
+
identifier: string
|
|
412
|
+
contentHtml: string
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
#### `FormShape`
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
interface FormShape {
|
|
420
|
+
responses: Record<string, {
|
|
421
|
+
cardinality: "single" | "multiple"
|
|
422
|
+
baseType: string
|
|
423
|
+
}>
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
#### `ValidateResult`
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
interface ValidateResult {
|
|
431
|
+
overallCorrect: boolean
|
|
432
|
+
feedbackHtml: string
|
|
433
|
+
selectedChoicesByResponse: Record<string, Array<{ id: string; isCorrect: boolean }>>
|
|
434
|
+
perResponse: Record<string, { isCorrect: boolean; messageHtml?: string }>
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Theming
|
|
441
|
+
|
|
442
|
+
The package includes two built-in themes and supports custom themes via CSS variables.
|
|
443
|
+
|
|
444
|
+
### Built-in Themes
|
|
445
|
+
|
|
446
|
+
#### Duolingo (Default)
|
|
447
|
+
|
|
448
|
+
Clean, friendly design inspired by Duolingo's learning interface.
|
|
109
449
|
|
|
110
450
|
```tsx
|
|
111
451
|
<QTIRenderer theme="duolingo" ... />
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
#### Neobrutalist
|
|
455
|
+
|
|
456
|
+
Bold, high-contrast design with thick borders and sharp shadows.
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
112
459
|
<QTIRenderer theme="neobrutalist" ... />
|
|
113
460
|
```
|
|
114
461
|
|
|
@@ -117,54 +464,251 @@ Then set the theme prop:
|
|
|
117
464
|
Create your own theme by overriding CSS variables:
|
|
118
465
|
|
|
119
466
|
```css
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
--qti-
|
|
124
|
-
|
|
467
|
+
/* In your globals.css */
|
|
468
|
+
[data-qti-theme="my-custom-theme"] {
|
|
469
|
+
/* Container styling */
|
|
470
|
+
--qti-container-bg: #ffffff;
|
|
471
|
+
--qti-container-border: #e0e0e0;
|
|
472
|
+
--qti-container-border-width: 1px;
|
|
473
|
+
--qti-container-radius: 12px;
|
|
474
|
+
--qti-container-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
475
|
+
|
|
476
|
+
/* Choice styling */
|
|
477
|
+
--qti-choice-bg: #f8f8f8;
|
|
478
|
+
--qti-choice-border: #d0d0d0;
|
|
479
|
+
--qti-choice-hover-bg: #f0f0f0;
|
|
480
|
+
--qti-choice-selected-bg: #e8f4fd;
|
|
481
|
+
--qti-choice-selected-border: #2196f3;
|
|
482
|
+
--qti-choice-correct-bg: #e8f5e9;
|
|
483
|
+
--qti-choice-correct-border: #4caf50;
|
|
484
|
+
--qti-choice-incorrect-bg: #ffebee;
|
|
485
|
+
--qti-choice-incorrect-border: #f44336;
|
|
486
|
+
|
|
487
|
+
/* Button styling */
|
|
488
|
+
--qti-button-bg: #2196f3;
|
|
489
|
+
--qti-button-text: #ffffff;
|
|
490
|
+
--qti-button-border: transparent;
|
|
491
|
+
--qti-button-shadow: none;
|
|
492
|
+
|
|
493
|
+
/* Typography */
|
|
494
|
+
--qti-font-family: system-ui, sans-serif;
|
|
495
|
+
--qti-font-weight: 400;
|
|
496
|
+
|
|
497
|
+
/* Feedback styling */
|
|
498
|
+
--qti-feedback-bg: #ffffff;
|
|
499
|
+
--qti-feedback-border: #e0e0e0;
|
|
500
|
+
--qti-feedback-correct-text: #2e7d32;
|
|
501
|
+
--qti-feedback-incorrect-text: #c62828;
|
|
125
502
|
}
|
|
126
503
|
```
|
|
127
504
|
|
|
128
|
-
|
|
505
|
+
Then use it:
|
|
129
506
|
|
|
130
|
-
|
|
507
|
+
```tsx
|
|
508
|
+
<QTIRenderer theme="my-custom-theme" ... />
|
|
509
|
+
```
|
|
131
510
|
|
|
132
|
-
|
|
133
|
-
|------|------|-------------|
|
|
134
|
-
| `item` | `AssessmentItem` | The parsed QTI assessment item |
|
|
135
|
-
| `theme` | `string` | Theme name (default: "duolingo") |
|
|
136
|
-
| `responses` | `Record<string, string[]>` | Current user responses |
|
|
137
|
-
| `onResponseChange` | `(id, values) => void` | Called when user changes response |
|
|
138
|
-
| `showFeedback` | `boolean` | Whether to show feedback |
|
|
139
|
-
| `feedback` | `ResponseFeedback` | Overall feedback data |
|
|
140
|
-
| `disabled` | `boolean` | Disable all interactions |
|
|
511
|
+
---
|
|
141
512
|
|
|
142
|
-
|
|
513
|
+
## Complete Examples
|
|
143
514
|
|
|
144
|
-
|
|
515
|
+
### Example 1: Simple Quiz Page
|
|
145
516
|
|
|
146
|
-
```
|
|
147
|
-
|
|
517
|
+
```tsx
|
|
518
|
+
// app/quiz/[id]/page.tsx
|
|
519
|
+
import * as React from "react"
|
|
520
|
+
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
|
|
521
|
+
import { db } from "@/db"
|
|
522
|
+
import { questions } from "@/db/schema"
|
|
523
|
+
import { eq } from "drizzle-orm"
|
|
524
|
+
import { QuizClient } from "./client"
|
|
525
|
+
|
|
526
|
+
export default function QuizPage({ params }: { params: Promise<{ id: string }> }) {
|
|
527
|
+
const displayPromise = params.then(async (p) => {
|
|
528
|
+
const question = await db.query.questions.findFirst({
|
|
529
|
+
where: eq(questions.id, p.id)
|
|
530
|
+
})
|
|
531
|
+
if (!question) throw new Error("Question not found")
|
|
532
|
+
return buildDisplayModel(question.xml)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
async function validate(responses: Record<string, string | string[]>) {
|
|
536
|
+
"use server"
|
|
537
|
+
const { id } = await params
|
|
538
|
+
const question = await db.query.questions.findFirst({
|
|
539
|
+
where: eq(questions.id, id)
|
|
540
|
+
})
|
|
541
|
+
if (!question) throw new Error("Question not found")
|
|
542
|
+
return validateResponsesSecure(question.xml, responses)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<React.Suspense fallback={<LoadingSkeleton />}>
|
|
547
|
+
<QuizClient displayPromise={displayPromise} onValidate={validate} />
|
|
548
|
+
</React.Suspense>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
148
551
|
```
|
|
149
552
|
|
|
150
|
-
###
|
|
553
|
+
### Example 2: Multi-Question Assessment
|
|
151
554
|
|
|
152
|
-
|
|
555
|
+
```tsx
|
|
556
|
+
// app/assessment/[id]/page.tsx
|
|
557
|
+
import * as React from "react"
|
|
558
|
+
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
|
|
559
|
+
import { AssessmentClient } from "./client"
|
|
560
|
+
|
|
561
|
+
export default function AssessmentPage({ params }: { params: Promise<{ id: string }> }) {
|
|
562
|
+
// Fetch all questions for this assessment
|
|
563
|
+
const questionsPromise = params.then(async (p) => {
|
|
564
|
+
const assessment = await fetchAssessment(p.id)
|
|
565
|
+
return Promise.all(
|
|
566
|
+
assessment.questionIds.map(async (qId) => {
|
|
567
|
+
const xml = await fetchQuestionXml(qId)
|
|
568
|
+
const display = await buildDisplayModel(xml)
|
|
569
|
+
return { id: qId, xml, ...display }
|
|
570
|
+
})
|
|
571
|
+
)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// Generic validator that works for any question
|
|
575
|
+
async function validateQuestion(questionId: string, responses: Record<string, string | string[]>) {
|
|
576
|
+
"use server"
|
|
577
|
+
const xml = await fetchQuestionXml(questionId)
|
|
578
|
+
return validateResponsesSecure(xml, responses)
|
|
579
|
+
}
|
|
153
580
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
581
|
+
return (
|
|
582
|
+
<React.Suspense fallback={<div>Loading assessment...</div>}>
|
|
583
|
+
<AssessmentClient
|
|
584
|
+
questionsPromise={questionsPromise}
|
|
585
|
+
onValidate={validateQuestion}
|
|
586
|
+
/>
|
|
587
|
+
</React.Suspense>
|
|
588
|
+
)
|
|
589
|
+
}
|
|
157
590
|
```
|
|
158
591
|
|
|
159
|
-
|
|
592
|
+
### Example 3: With Theming Toggle
|
|
593
|
+
|
|
594
|
+
```tsx
|
|
595
|
+
// app/quiz/[id]/client.tsx
|
|
596
|
+
"use client"
|
|
597
|
+
|
|
598
|
+
import * as React from "react"
|
|
599
|
+
import { QTIRenderer } from "@superbuilders/incept-renderer"
|
|
600
|
+
import type { DisplayItem, FormShape, ValidateResult } from "@superbuilders/incept-renderer"
|
|
601
|
+
|
|
602
|
+
const THEMES = [
|
|
603
|
+
{ value: "duolingo", label: "Duolingo" },
|
|
604
|
+
{ value: "neobrutalist", label: "Neobrutalist" },
|
|
605
|
+
] as const
|
|
606
|
+
|
|
607
|
+
type Theme = typeof THEMES[number]["value"]
|
|
608
|
+
|
|
609
|
+
export function QuizClient({ displayPromise, onValidate }: {
|
|
610
|
+
displayPromise: Promise<{ item: DisplayItem; shape: FormShape; itemKey: string }>
|
|
611
|
+
onValidate: (r: Record<string, string | string[]>) => Promise<ValidateResult>
|
|
612
|
+
}) {
|
|
613
|
+
const { item } = React.use(displayPromise)
|
|
614
|
+
|
|
615
|
+
const [responses, setResponses] = React.useState<Record<string, string | string[]>>({})
|
|
616
|
+
const [result, setResult] = React.useState<ValidateResult | undefined>()
|
|
617
|
+
const [showFeedback, setShowFeedback] = React.useState(false)
|
|
618
|
+
const [theme, setTheme] = React.useState<Theme>("duolingo")
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<div>
|
|
622
|
+
{/* Theme Selector */}
|
|
623
|
+
<div className="mb-4 flex justify-end">
|
|
624
|
+
<select
|
|
625
|
+
value={theme}
|
|
626
|
+
onChange={(e) => setTheme(e.target.value as Theme)}
|
|
627
|
+
className="border rounded px-2 py-1"
|
|
628
|
+
>
|
|
629
|
+
{THEMES.map((t) => (
|
|
630
|
+
<option key={t.value} value={t.value}>{t.label}</option>
|
|
631
|
+
))}
|
|
632
|
+
</select>
|
|
633
|
+
</div>
|
|
634
|
+
|
|
635
|
+
{/* Question Renderer */}
|
|
636
|
+
<QTIRenderer
|
|
637
|
+
item={item}
|
|
638
|
+
responses={responses}
|
|
639
|
+
onResponseChange={(id, val) => setResponses(prev => ({ ...prev, [id]: val }))}
|
|
640
|
+
showFeedback={showFeedback}
|
|
641
|
+
theme={theme}
|
|
642
|
+
overallFeedback={result ? {
|
|
643
|
+
isCorrect: result.overallCorrect,
|
|
644
|
+
messageHtml: result.feedbackHtml
|
|
645
|
+
} : undefined}
|
|
646
|
+
choiceCorrectness={result?.selectedChoicesByResponse}
|
|
647
|
+
/>
|
|
648
|
+
|
|
649
|
+
{/* Check Button */}
|
|
650
|
+
<button
|
|
651
|
+
onClick={async () => {
|
|
652
|
+
const validation = await onValidate(responses)
|
|
653
|
+
setResult(validation)
|
|
654
|
+
setShowFeedback(true)
|
|
655
|
+
}}
|
|
656
|
+
disabled={showFeedback}
|
|
657
|
+
className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"
|
|
658
|
+
>
|
|
659
|
+
Check Answer
|
|
660
|
+
</button>
|
|
661
|
+
</div>
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## Supported Interactions
|
|
669
|
+
|
|
670
|
+
| Interaction Type | Support | Description |
|
|
671
|
+
|------------------|---------|-------------|
|
|
672
|
+
| `choiceInteraction` | ✅ Full | Single/multiple choice questions |
|
|
673
|
+
| `inlineChoiceInteraction` | ✅ Full | Dropdown selections within text |
|
|
674
|
+
| `textEntryInteraction` | ✅ Full | Free-text input fields |
|
|
675
|
+
| `orderInteraction` | 🔶 Basic | Drag-and-drop ordering |
|
|
676
|
+
| `matchInteraction` | 🔶 Basic | Matching pairs |
|
|
677
|
+
| `gapMatchInteraction` | 🔶 Basic | Fill-in-the-blank with draggable options |
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## FAQ
|
|
682
|
+
|
|
683
|
+
### How do I store QTI XML?
|
|
684
|
+
|
|
685
|
+
Store it however works best for your app:
|
|
686
|
+
- **Database:** Store as a TEXT/VARCHAR column
|
|
687
|
+
- **File system:** Store as `.xml` files
|
|
688
|
+
- **External API:** Fetch from a QTI content server
|
|
689
|
+
|
|
690
|
+
The package doesn't care where the XML comes from—it just needs a string.
|
|
691
|
+
|
|
692
|
+
### Can users cheat by inspecting the browser?
|
|
693
|
+
|
|
694
|
+
No! Correct answers are **never sent to the browser**. The `buildDisplayModel` function only extracts display data. Validation happens on the server with the original XML.
|
|
695
|
+
|
|
696
|
+
### How do I add custom styling?
|
|
697
|
+
|
|
698
|
+
Use the `theme` prop with a custom theme name, then define CSS variables for `[data-qti-theme="your-theme"]` in your stylesheet.
|
|
699
|
+
|
|
700
|
+
### Can I use this without Next.js?
|
|
701
|
+
|
|
702
|
+
The package is designed for Next.js server components and server actions. For other frameworks, you'd need to:
|
|
703
|
+
1. Create your own server endpoint for `buildDisplayModel` and `validateResponsesSecure`
|
|
704
|
+
2. Call those endpoints from your client
|
|
705
|
+
|
|
706
|
+
### How do I handle MathML/LaTeX?
|
|
707
|
+
|
|
708
|
+
The package preserves MathML in the HTML output. Ensure your app has MathML rendering support (modern browsers handle it natively, or use MathJax/KaTeX for broader support).
|
|
160
709
|
|
|
161
|
-
|
|
162
|
-
- ✅ Inline Choice Interaction (dropdowns)
|
|
163
|
-
- ✅ Text Entry Interaction
|
|
164
|
-
- 🚧 Order Interaction
|
|
165
|
-
- 🚧 Gap Match Interaction
|
|
166
|
-
- 🚧 Match Interaction
|
|
710
|
+
---
|
|
167
711
|
|
|
168
712
|
## License
|
|
169
713
|
|
|
170
|
-
MIT
|
|
714
|
+
MIT © Superbuilders
|