@superbuilders/incept-renderer 0.1.11 → 0.1.12

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
@@ -12,6 +12,7 @@ A secure, server-driven QTI 3.0 assessment renderer for React/Next.js applicatio
12
12
  - [Server Functions](#server-functions)
13
13
  - [Client Components](#client-components)
14
14
  - [Types](#types)
15
+ - [Rendering Stimuli](#rendering-stimuli)
15
16
  - [Theming](#theming)
16
17
  - [Complete Examples](#complete-examples)
17
18
  - [Supported Interactions](#supported-interactions)
@@ -317,6 +318,29 @@ const result = await validateResponsesSecure(qtiXmlString, {
317
318
  }
318
319
  ```
319
320
 
321
+ #### `parseAssessmentStimulusXml(xml: string)`
322
+
323
+ Parses QTI Assessment Stimulus XML (reading materials, passages).
324
+
325
+ ```tsx
326
+ import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"
327
+
328
+ const stimulus = parseAssessmentStimulusXml(stimulusXmlString)
329
+ ```
330
+
331
+ **Parameters:**
332
+ - `xml` (string) - Raw QTI 3.0 Assessment Stimulus XML string
333
+
334
+ **Returns:**
335
+ ```tsx
336
+ {
337
+ identifier: string // Unique identifier from the XML
338
+ title: string // Human-readable title
339
+ xmlLang: string // Language code (e.g., "en")
340
+ bodyHtml: string // Sanitized HTML content ready for rendering
341
+ }
342
+ ```
343
+
320
344
  ---
321
345
 
322
346
  ### Client Components
@@ -357,6 +381,26 @@ import { QTIRenderer } from "@superbuilders/incept-renderer"
357
381
  | `responseFeedback` | `Record<string, { isCorrect: boolean; messageHtml?: string }>` | ❌ | Per-response feedback messages |
358
382
  | `theme` | `"duolingo" \| "neobrutalist" \| string` | ❌ | Visual theme (default: `"duolingo"`) |
359
383
 
384
+ #### `<QTIStimulusRenderer />`
385
+
386
+ Renders QTI Assessment Stimuli (reading materials, passages).
387
+
388
+ ```tsx
389
+ import { QTIStimulusRenderer } from "@superbuilders/incept-renderer"
390
+
391
+ <QTIStimulusRenderer
392
+ stimulus={parsedStimulus}
393
+ className="my-8"
394
+ />
395
+ ```
396
+
397
+ **Props:**
398
+
399
+ | Prop | Type | Required | Description |
400
+ |------|------|----------|-------------|
401
+ | `stimulus` | `AssessmentStimulus` | ✅ | Parsed stimulus from `parseAssessmentStimulusXml` |
402
+ | `className` | `string` | ❌ | Additional CSS classes for the container |
403
+
360
404
  ---
361
405
 
362
406
  ### Types
@@ -365,12 +409,15 @@ Import from `@superbuilders/incept-renderer`:
365
409
 
366
410
  ```tsx
367
411
  import type {
412
+ // Assessment Items (questions)
368
413
  DisplayItem,
369
414
  DisplayBlock,
370
415
  DisplayChoice,
371
416
  DisplayChoiceInteraction,
372
417
  FormShape,
373
- ValidateResult
418
+ ValidateResult,
419
+ // Assessment Stimuli (reading materials)
420
+ AssessmentStimulus
374
421
  } from "@superbuilders/incept-renderer"
375
422
  ```
376
423
 
@@ -435,6 +482,158 @@ interface ValidateResult {
435
482
  }
436
483
  ```
437
484
 
485
+ #### `AssessmentStimulus`
486
+
487
+ ```tsx
488
+ interface AssessmentStimulus {
489
+ identifier: string // Unique identifier from the XML
490
+ title: string // Human-readable title
491
+ xmlLang: string // Language code (e.g., "en", "es")
492
+ bodyHtml: string // Sanitized HTML content
493
+ }
494
+ ```
495
+
496
+ ---
497
+
498
+ ## Rendering Stimuli
499
+
500
+ In addition to assessment items (questions), the package supports rendering **Assessment Stimuli** — reading materials, passages, or articles that provide context for questions.
501
+
502
+ ### What are Stimuli?
503
+
504
+ QTI Assessment Stimuli (`qti-assessment-stimulus`) are standalone content blocks that contain:
505
+ - Reading passages
506
+ - Articles with images
507
+ - Reference materials
508
+ - Any HTML content that accompanies questions
509
+
510
+ Unlike assessment items, stimuli have no interactions or correct answers — they're purely display content.
511
+
512
+ ### Parsing Stimulus XML
513
+
514
+ Use `parseAssessmentStimulusXml` to parse stimulus XML:
515
+
516
+ ```tsx
517
+ import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"
518
+
519
+ const stimulusXml = `<?xml version="1.0" encoding="UTF-8"?>
520
+ <qti-assessment-stimulus
521
+ xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
522
+ identifier="stimulus-1"
523
+ xml:lang="en"
524
+ title="Biodiversity and Ecosystem Health">
525
+ <qti-stimulus-body>
526
+ <h2>Biodiversity and ecosystem health</h2>
527
+ <p><strong>Biodiversity</strong> is the variety of species in an ecosystem.</p>
528
+ <figure>
529
+ <img src="coral-reef.jpg" alt="A coral reef" />
530
+ <figcaption>Coral reef ecosystems have high biodiversity.</figcaption>
531
+ </figure>
532
+ </qti-stimulus-body>
533
+ </qti-assessment-stimulus>`
534
+
535
+ const stimulus = parseAssessmentStimulusXml(stimulusXml)
536
+ // Returns: { identifier, title, xmlLang, bodyHtml }
537
+ ```
538
+
539
+ ### Rendering Stimuli
540
+
541
+ Use the `QTIStimulusRenderer` component to render parsed stimuli:
542
+
543
+ ```tsx
544
+ import { QTIStimulusRenderer, parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"
545
+
546
+ function ReadingPassage({ xml }: { xml: string }) {
547
+ const stimulus = parseAssessmentStimulusXml(xml)
548
+
549
+ return (
550
+ <QTIStimulusRenderer
551
+ stimulus={stimulus}
552
+ className="my-8"
553
+ />
554
+ )
555
+ }
556
+ ```
557
+
558
+ ### Server Action Pattern
559
+
560
+ For Next.js apps, create a server action to parse stimuli:
561
+
562
+ ```tsx
563
+ // lib/qti-actions.ts
564
+ "use server"
565
+
566
+ import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"
567
+
568
+ export async function parseStimulus(xml: string) {
569
+ return parseAssessmentStimulusXml(xml)
570
+ }
571
+ ```
572
+
573
+ ```tsx
574
+ // app/reading/[id]/page.tsx
575
+ import { parseStimulus } from "@/lib/qti-actions"
576
+ import { QTIStimulusRenderer } from "@superbuilders/incept-renderer"
577
+
578
+ export default async function ReadingPage({ params }: { params: Promise<{ id: string }> }) {
579
+ const { id } = await params
580
+ const xml = await fetchStimulusXml(id) // Your data fetching
581
+ const stimulus = await parseStimulus(xml)
582
+
583
+ return <QTIStimulusRenderer stimulus={stimulus} />
584
+ }
585
+ ```
586
+
587
+ ### Stimulus with Questions
588
+
589
+ A common pattern is showing a stimulus alongside related questions:
590
+
591
+ ```tsx
592
+ import { QTIStimulusRenderer, QTIRenderer } from "@superbuilders/incept-renderer"
593
+
594
+ function QuestionWithPassage({ stimulus, item, ...props }) {
595
+ return (
596
+ <div className="grid grid-cols-2 gap-8">
597
+ {/* Reading passage on the left */}
598
+ <div className="overflow-y-auto max-h-[80vh]">
599
+ <QTIStimulusRenderer stimulus={stimulus} />
600
+ </div>
601
+
602
+ {/* Question on the right */}
603
+ <div>
604
+ <QTIRenderer item={item} {...props} />
605
+ </div>
606
+ </div>
607
+ )
608
+ }
609
+ ```
610
+
611
+ ### AssessmentStimulus Type
612
+
613
+ ```tsx
614
+ interface AssessmentStimulus {
615
+ identifier: string // Unique ID from the XML
616
+ title: string // Human-readable title
617
+ xmlLang: string // Language code (e.g., "en")
618
+ bodyHtml: string // Sanitized HTML content
619
+ }
620
+ ```
621
+
622
+ ### Supported Content
623
+
624
+ The stimulus renderer supports:
625
+ - **Headings** (`h1`–`h6`)
626
+ - **Text formatting** (`p`, `strong`, `em`, `b`, `i`, `u`, `sub`, `sup`)
627
+ - **Lists** (`ul`, `ol`, `li`)
628
+ - **Images** (`img`, `figure`, `figcaption`)
629
+ - **Tables** (`table`, `thead`, `tbody`, `tr`, `th`, `td`)
630
+ - **Links** (`a`)
631
+ - **MathML** (mathematical expressions)
632
+ - **Collapsible sections** (`details`, `summary`)
633
+ - **Semantic elements** (`article`, `section`, `blockquote`, `cite`)
634
+
635
+ All content is sanitized to prevent XSS attacks while preserving safe HTML structure.
636
+
438
637
  ---
439
638
 
440
639
  ## Theming
@@ -1,4 +1,7 @@
1
- import { g as DisplayItem, F as FormShape, V as ValidateResult } from '../types-B7YRTQKt.js';
1
+ export { a as parseAssessmentStimulusXml } from '../parser-B8n3iHSM.js';
2
+ import { k as DisplayItem, m as FormShape, V as ValidateResult } from '../schema-DKduufCs.js';
3
+ export { a as AssessmentStimulus } from '../schema-DKduufCs.js';
4
+ import 'zod';
2
5
 
3
6
  declare function buildDisplayModelFromXml(qtiXml: string): {
4
7
  itemKey: string;
@@ -1,10 +1,10 @@
1
- import { createHash } from 'crypto';
2
1
  import * as errors from '@superbuilders/errors';
3
- import * as logger2 from '@superbuilders/slog';
4
2
  import { XMLParser } from 'fast-xml-parser';
5
3
  import { z } from 'zod';
4
+ import { createHash } from 'crypto';
5
+ import * as logger2 from '@superbuilders/slog';
6
6
 
7
- // src/actions/internal/display.ts
7
+ // src/parser.ts
8
8
 
9
9
  // src/html/sanitize.ts
10
10
  var DEFAULT_CONFIG = {
@@ -16,6 +16,13 @@ var DEFAULT_CONFIG = {
16
16
  "div",
17
17
  "br",
18
18
  "hr",
19
+ // Headings
20
+ "h1",
21
+ "h2",
22
+ "h3",
23
+ "h4",
24
+ "h5",
25
+ "h6",
19
26
  // Formatting
20
27
  "b",
21
28
  "i",
@@ -65,6 +72,9 @@ var DEFAULT_CONFIG = {
65
72
  "figcaption",
66
73
  "blockquote",
67
74
  "cite",
75
+ // Interactive
76
+ "details",
77
+ "summary",
68
78
  // Links
69
79
  "a",
70
80
  // Forms (for future interactive elements)
@@ -575,6 +585,16 @@ var AssessmentItemSchema = z.object({
575
585
  itemBody: ItemBodySchema,
576
586
  responseProcessing: ResponseProcessingSchema
577
587
  });
588
+ var AssessmentStimulusSchema = z.object({
589
+ /** Unique identifier for the stimulus */
590
+ identifier: z.string().min(1),
591
+ /** Human-readable title */
592
+ title: z.string().default(""),
593
+ /** Language code (e.g., "en", "es") */
594
+ xmlLang: z.string().default("en"),
595
+ /** The HTML content of the stimulus body */
596
+ bodyHtml: z.string()
597
+ });
578
598
 
579
599
  // src/parser.ts
580
600
  function createXmlParser() {
@@ -1200,8 +1220,47 @@ function parseAssessmentItemXml(xml) {
1200
1220
  }
1201
1221
  return validation.data;
1202
1222
  }
1203
-
1204
- // src/actions/internal/display.ts
1223
+ function parseAssessmentStimulusXml(xml) {
1224
+ if (!xml || typeof xml !== "string") {
1225
+ throw errors.new("xml input must be a non-empty string");
1226
+ }
1227
+ const parser = createXmlParser();
1228
+ const parseResult = errors.trySync(() => {
1229
+ return parser.parse(xml, true);
1230
+ });
1231
+ if (parseResult.error) {
1232
+ throw errors.wrap(parseResult.error, "xml parse");
1233
+ }
1234
+ const raw = parseResult.data;
1235
+ if (!Array.isArray(raw)) {
1236
+ throw errors.new("expected xml parser to output an array for preserveOrder");
1237
+ }
1238
+ const normalizedTree = raw.map(normalizeNode).filter((n) => typeof n !== "string");
1239
+ const rootNode = normalizedTree.find(
1240
+ (n) => n.tagName === "qti-assessment-stimulus" || n.tagName.endsWith("assessment-stimulus")
1241
+ );
1242
+ if (!rootNode) {
1243
+ throw errors.new("qti assessment stimulus not found in xml document");
1244
+ }
1245
+ const rootChildren = rootNode.children.filter((c) => typeof c !== "string");
1246
+ const stimulusBodyNode = rootChildren.find((n) => n.tagName === "qti-stimulus-body");
1247
+ if (!stimulusBodyNode) {
1248
+ throw errors.new("qti-stimulus-body not found in stimulus document");
1249
+ }
1250
+ const bodyHtml = getInnerHtml(stimulusBodyNode);
1251
+ const normalizedStimulus = {
1252
+ identifier: coerceString(rootNode.attrs.identifier),
1253
+ title: coerceString(rootNode.attrs.title),
1254
+ xmlLang: coerceString(rootNode.attrs["xml:lang"]) || coerceString(rootNode.attrs["xml-lang"]) || "en",
1255
+ bodyHtml
1256
+ };
1257
+ const validation = AssessmentStimulusSchema.safeParse(normalizedStimulus);
1258
+ if (!validation.success) {
1259
+ const errorDetails = validation.error.issues.map((err) => `${err.path.join(".")}: ${err.message}`).join("; ");
1260
+ throw errors.new(`qti stimulus validation: ${errorDetails}`);
1261
+ }
1262
+ return validation.data;
1263
+ }
1205
1264
  function shuffleArray(items) {
1206
1265
  const arr = items.slice();
1207
1266
  for (let i = arr.length - 1; i > 0; i--) {
@@ -1688,6 +1747,6 @@ async function validateResponsesSecure(qtiXml, responses) {
1688
1747
  return validateResponsesFromXml(qtiXml, responses);
1689
1748
  }
1690
1749
 
1691
- export { buildDisplayModel, buildDisplayModelFromXml, validateResponsesFromXml, validateResponsesSecure };
1750
+ export { buildDisplayModel, buildDisplayModelFromXml, parseAssessmentStimulusXml, validateResponsesFromXml, validateResponsesSecure };
1692
1751
  //# sourceMappingURL=index.js.map
1693
1752
  //# sourceMappingURL=index.js.map