qti-html-renderer
v0.2.1
Published
Reusable QTI 3.0 assessment item HTML rendering utilities.
Maintainers
Readme
qti-html-renderer
Shared utilities for rendering QTI 3.0 assessment item XML into HTML.
Install
npm install qti-html-rendererUsage
import {
applyResponsesToPromptHtml,
renderQtiItemForExplanations,
renderQtiItemForReport,
renderQtiItemForScoring,
rewriteHtmlImageSources,
} from 'qti-html-renderer';Canonical QTI 3 presentation content
QTI 3 presentation content uses ordinary HTML elements such as p, div,
span, em, strong, a, pre, code, lists, tables, img, br, and
hr. Their authored attributes and child order are preserved in the rendered
HTML; xmlns declarations are omitted from emitted fragments. QTI-specific
elements remain qti-prefixed, including interactions, qti-content-body, and
qti-rubric-block.
Code blocks use the canonical structure <pre><code>...</code></pre>. A
configured codeHighlighter is used for text-only code. When a code block
contains nested HTML markup, its authored children are preserved and are not
replaced by highlighter output.
Rendering for scoring UI
Use this when you need prompt HTML, rubric criteria, choices, and optional explanation.
const parsed = renderQtiItemForScoring(xml);
parsed.identifier;
parsed.title;
parsed.promptHtml;
parsed.rubricCriteria;
parsed.choices;
parsed.interactions;
parsed.candidateExplanationHtml;You can customize generated HTML via options:
const parsed = renderQtiItemForScoring(xml, {
blankRenderer: (index) => `<input class="my-blank" data-blank="${index}" />`,
extendedTextRenderer: () => '<span class=answer-long>(long answer)</span>',
choiceListClassName: 'my-choice-list',
preWithBlanksClassName: 'my-pre-with-blanks',
});Rendering for reports
Use this when you need a full HTML fragment for reports with code highlighting hooks.
const reportItem = renderQtiItemForReport(xml, expectedIdentifier, {
clozeInputHtml: '<input class=cloze-input type=text readonly>',
choiceWrapperClassName: 'choice-interaction',
codeBlockClassName: 'code-block hljs',
codeBlockCodeClassName: 'code-block-code',
inlineCodeClassName: 'code-inline',
dataCodeLangAttribute: 'data-code-lang',
itemBodyWrapperClassName: 'item-body',
codeHighlighter: (code, explicitLanguage) => {
// return highlighted HTML plus language label
return { language: explicitLanguage ?? 'plain', html: code };
},
});
reportItem.questionHtml;
reportItem.interactions; // typed InteractionInfo[] for retry / correct attach
reportItem.explanationHtml; // null when the item has no qti-modal-feedback explanationThe report body wrapper element for qti-choice-interaction carries a
data-interaction-id="<response-identifier>" attribute, and the rendered cloze
<input> (when the default or any custom template that contains <input is
used) carries the same attribute. Consumers can attach correct values by id
without re-parsing the source XML.
Custom clozeInputHtml templates that do not contain an <input element are
left untouched.
Interaction binding
InteractionInfo is the public contract every consumer (retry UI, correct
attach, scoring) MUST use to look up the correct response for an interaction.
Two binding rules apply:
- Direct match — when the interaction's
response-identifierequals aqti-response-declarationidentifier, the interaction gets the matching declaration's values in full. A direct match is only honored when the identifier was carried by exactly oneqti-response-declarationelement in the XML. When two or moreqti-response-declarationelements share the same identifier, the structure is ambiguous and the renderer does NOT silently pick one and discard the others: the duplicate count is tracked per identifier, the identifier is removed from the trusted direct-match set, and the affected interactions are reported as unmatched (declarationIdentifier: null,correctResponse: []). This applies to both choice and text-entry interactions — there is no last-wins fallback for duplicated identifiers. - Legacy ordered
RESPONSEdistribution — applies only to the pure cloze shape it was designed for. ALL of the following must hold: there is exactly oneqti-response-declarationelement in the XML (this is a raw element count, not aMap.sizecheck — two declarations sharing an identifier collapse to a singleMapentry and would otherwise pass an equality test the XML does not actually satisfy); itsidentifieris exactlyRESPONSEwithcardinality="ordered"andbase-type="string"; no interaction matches a declaration directly; every interaction in the item is unmatched; every interaction's publishedtypeis exactlytext-entry(custom or non-standard interactions reported asotherare excluded); the unmatchedresponse-identifiers are exactlyRESPONSE_1..RESPONSE_Nin document order with no gaps and no duplicates; and the value count equals the interaction count. Then the declaration's values are distributed to the interactions in document order anddeclarationValueIndexrecords the 0-based position assigned to each one. If any interaction matches a declaration directly — for example a literalRESPONSEinteraction next to aRESPONSE_1interaction — the fallback does not fire: theRESPONSEinteraction wins by direct match andRESPONSE_1stays unmatched.
If neither rule applies, the interaction's declarationIdentifier,
declarationValueIndex, cardinality, and baseType are null and
correctResponse is []. Consumers must treat correctResponse: [] as
"no declared correct value" rather than guessing from a fallback.
Rendering explanations
When you only need the rendered explanation body (no question, no rubric),
call renderQtiItemForExplanations. It applies the same
normalizePreBlocks → enhanceCodeBlocks → enhanceInlineCode flow as the
report body, so the resulting HTML has the same code-block /
code-block-code / code-inline / data-code-lang contract.
const explanation = renderQtiItemForExplanations(xml, expectedIdentifier, {
codeHighlighter: (code, explicitLanguage) => ({ language: explicitLanguage ?? 'plain', html: code }),
});
explanation.explanationHtml; // null when the item has no qti-modal-feedbackExplanationRenderOptions:
codeHighlighter(code, explicitLanguage) => CodeHighlightResult— optional highlighter applied to<pre><code>blocks the same way the report path applies it.
explanationHtml is null when the item has no qti-modal-feedback, no
qti-content-body, or the body is empty / contains only whitespace text
nodes / contains only XML comments. The meaningful-content check is kept in
sync with what the report and scoring renderers actually emit, so an
explanation body whose only content is one of the self-displaying HTML
elements img, hr, or br is reported as meaningful (non-null, non-empty
HTML) in both
renderQtiItemForExplanations.explanationHtml and
renderQtiItemForScoring.candidateExplanationHtml. Conversely, a body that
contains only qti-rubric-block (which every renderer collapses to '') or
only empty container elements (p, div, lists, tables) is still reported as
null in both paths.
HTML utilities
const rewritten = rewriteHtmlImageSources(html, baseFilePath, {
resolveUrl: (resolvedPath) => `/assets/${resolvedPath}`,
});
const withResponses = applyResponsesToPromptHtml(promptHtml, responses);Node.js DOMParser
applyResponsesToPromptHtml and rewriteHtmlImageSources require a DOMParser in Node.js.
Pass one via options:
import { JSDOM } from 'jsdom';
const domParser = new JSDOM('').window.DOMParser();
const withResponses = applyResponsesToPromptHtml(promptHtml, responses, { domParser });
const rewritten = rewriteHtmlImageSources(html, baseFilePath, {
domParser,
resolveUrl: (resolvedPath) => `/assets/${resolvedPath}`,
});Return Types
renderQtiItemForScoring→{ identifier, title, promptHtml, rubricCriteria, choices, interactions, candidateExplanationHtml }renderQtiItemForReport→{ identifier, title, questionHtml, rubricCriteria, itemMaxScore, choices, interactions, explanationHtml }renderQtiItemForExplanations→{ identifier, title, explanationHtml }
InteractionInfo shape (exposed on both ParsedItemForScoring.interactions
and ParsedItemForReport.interactions):
interface InteractionInfo {
id: string; // the response-identifier on the interaction element
type: 'choice' | 'text-entry' | 'extended-text' | 'other';
declarationIdentifier: string | null; // the qti-response-declaration identifier that bound to this interaction
declarationValueIndex: number | null; // 0-based index into the declaration's values (legacy ordered RESPONSE distribution only; null otherwise)
cardinality: 'single' | 'multiple' | 'ordered' | null; // from the declaration, normalized; null if absent
baseType: string | null; // from the declaration; null if absent
correctResponse: string[]; // values in document order; newlines normalized to \n, then base-type="string" preserves surrounding whitespace while other base-types are trimmed
choices: ChoiceOption[]; // for choice interactions, this interaction's own qti-simple-choice children
maxChoices: number | null; // parsed from max-choices, only meaningful for choice interactions
}correctResponse values are normalized per declaration base-type. Every
newline style is first normalized to \n. When base-type="string", the
value preserves surrounding whitespace, indentation, and blank lines (no
.trim()). For every other base-type (identifier, boolean, integer,
float, ..., and the unspecified case) the value is trimmed of surrounding
whitespace, because that whitespace is never part of the value for those
types. extended-text string answers therefore keep their whitespace, while
identifier choice answers like CHOICE_B are returned trimmed.
Development
npm ci
npm run build # tsc -p tsconfig.json (also runs automatically via `prepack`)
npm test # build + node --test
npm run verify # prettier --check + eslint + typecheck + test
npm run lint
npm run formatRelease
This package is published to npm with npm publish --access public after the
release commit has been pushed. An authenticated npm session is required.
Release steps:
- Set the same version in
package.json,package-lock.json, andCHANGELOG.md. - Push the change to
mainand wait for the normal CI to pass. - Run
npm publish --access public. - Create and push the matching
v<version>annotated tag (for examplev0.2.1), then verify the tag points to the release commit.
