@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 CHANGED
@@ -1,114 +1,461 @@
1
- # @alpha/qti-renderer
1
+ # @superbuilders/incept-renderer
2
2
 
3
- A QTI 3.0 Assessment Renderer for React applications. Parse, validate, and render QTI XML with customizable themes.
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
- ## Features
5
+ ## Table of Contents
6
6
 
7
- - 📝 **QTI 3.0 Support** - Parse and render QTI 3.0 assessment items
8
- - 🎨 **Themeable** - Built-in Duolingo and Neobrutalist themes, or create your own
9
- - 🔒 **Secure** - Server-side parsing and validation keeps correct answers hidden
10
- - ⚛️ **React 18/19** - Works with latest React and Next.js
11
- - 📦 **Tree-shakeable** - Only import what you need
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 @alpha/qti-renderer
25
+ npm install @superbuilders/incept-renderer
26
+ # or
27
+ bun add @superbuilders/incept-renderer
17
28
  # or
18
- bun add @alpha/qti-renderer
29
+ yarn add @superbuilders/incept-renderer
19
30
  ```
20
31
 
21
- ## Quick Start
32
+ ### Peer Dependencies
22
33
 
23
- ### 1. Parse QTI XML (Server-side)
34
+ This package requires:
35
+ - `react` >= 18.0.0
36
+ - `react-dom` >= 18.0.0
24
37
 
25
- ```tsx
26
- // app/quiz/[id]/page.tsx (Server Component)
27
- import { parseAssessmentItemXml, validateResponse } from "@alpha/qti-renderer/server"
28
- import { QTIRenderer } from "@alpha/qti-renderer"
29
- import "@alpha/qti-renderer/themes/duolingo.css"
38
+ For full functionality with Tailwind CSS theming, ensure your app has Tailwind configured.
39
+
40
+ ---
41
+
42
+ ## Core Concepts
30
43
 
31
- export default async function QuizPage({ params }) {
32
- // Fetch and parse QTI XML on the server
33
- const xml = await fetchQTIFromDatabase(params.id)
34
- const { item } = await parseAssessmentItemXml(xml)
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
- // Create server action for validation
37
- async function checkAnswer(responses: Record<string, string[]>) {
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
- return validateResponse(item, responses)
109
+ const { id } = await params
110
+ const xml = await getQtiXmlFromYourDatabase(id)
111
+ return validateResponsesSecure(xml, responses)
40
112
  }
41
113
 
42
- return <QuizClient item={item} onValidate={checkAnswer} />
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. Render and Handle Interactions (Client-side)
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 { useState } from "react"
53
- import { QTIRenderer } from "@alpha/qti-renderer"
54
- import type { AssessmentItem } from "@alpha/qti-renderer"
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 Props {
57
- item: AssessmentItem
58
- onValidate: (responses: Record<string, string[]>) => Promise<ValidationResult>
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({ item, onValidate }: Props) {
62
- const [responses, setResponses] = useState<Record<string, string[]>>({})
63
- const [feedback, setFeedback] = useState(null)
64
- const [showFeedback, setShowFeedback] = useState(false)
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
- const result = await onValidate(responses)
68
- setFeedback(result)
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={(id, values) =>
79
- setResponses(prev => ({ ...prev, [id]: values }))
80
- }
182
+ onResponseChange={handleResponseChange}
81
183
  showFeedback={showFeedback}
82
- feedback={feedback}
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
- <button onClick={handleCheck}>Check Answer</button>
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
- ## Themes
224
+ ---
92
225
 
93
- ### Built-in Themes
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
- Import the CSS for your chosen theme:
387
+ #### `DisplayBlock`
96
388
 
97
389
  ```tsx
98
- // Duolingo-style (rounded, friendly)
99
- import "@alpha/qti-renderer/themes/duolingo.css"
390
+ type DisplayBlock =
391
+ | { type: "html"; html: string }
392
+ | { type: "interaction"; interaction: DisplayChoiceInteraction }
393
+ ```
100
394
 
101
- // Neobrutalist (bold, sharp)
102
- import "@alpha/qti-renderer/themes/neobrutalist.css"
395
+ #### `DisplayChoiceInteraction`
103
396
 
104
- // Base only (minimal styling)
105
- import "@alpha/qti-renderer/themes/base.css"
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
- Then set the theme prop:
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
- [data-qti-theme="my-theme"] {
121
- --qti-container-bg: #1a1a2e;
122
- --qti-container-border: #ffffff;
123
- --qti-choice-selected-bg: #ff6b6b;
124
- /* ... see base.css for all variables */
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
- ## API Reference
505
+ Then use it:
129
506
 
130
- ### `<QTIRenderer />`
507
+ ```tsx
508
+ <QTIRenderer theme="my-custom-theme" ... />
509
+ ```
131
510
 
132
- | Prop | Type | Description |
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
- ### `parseAssessmentItemXml(xml)`
513
+ ## Complete Examples
143
514
 
144
- Parses QTI XML into an `AssessmentItem`. Server-side only.
515
+ ### Example 1: Simple Quiz Page
145
516
 
146
- ```ts
147
- const { success, item, error } = await parseAssessmentItemXml(xml)
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
- ### `validateResponse(item, responses)`
553
+ ### Example 2: Multi-Question Assessment
151
554
 
152
- Validates user responses against correct answers. Server-side only.
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
- ```ts
155
- const result = await validateResponse(item, responses)
156
- // { isCorrect, responses, feedbackHtml, score, maxScore }
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
- ## Supported Interaction Types
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
- - ✅ Choice Interaction (single & multiple)
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