qti-html-renderer
v0.1.3
Published
Reusable QTI 3.0 assessment item HTML rendering utilities.
Downloads
676
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';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 QTI-prefixed
self-displaying elements qti-img, qti-hr, or qti-br is reported as
meaningful (non-null, non-empty HTML) in both
renderQtiItemForExplanations.explanationHtml and
renderQtiItemForScoring.candidateExplanationHtml. Bare HTML img, hr,
and br spellings are not in the QTI 3.0 item body vocabulary and are not
treated as meaningful on their own. Conversely, a body that contains only
qti-rubric-block (which every renderer collapses to '') or only empty
container elements (qti-p, qti-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 only by GitHub Actions through npm Trusted
Publisher (OIDC). Local npm publish is not allowed, and no npm token is
required or used anywhere (no NPM_TOKEN, no NODE_AUTH_TOKEN, no token in
.npmrc).
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. - Create and push the matching
v<version>annotated tag (for examplev0.1.3). - The tag push triggers
.github/workflows/publish.yml, which automatically verifies tag/version agreement and package contents, publishes via OIDC, and re-verifies the published artifact from the registry.
An already-published version cannot be re-published; the workflow fails before publish if the version already exists on the registry.
