npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

qti-html-renderer

v0.1.3

Published

Reusable QTI 3.0 assessment item HTML rendering utilities.

Downloads

676

Readme

qti-html-renderer

Shared utilities for rendering QTI 3.0 assessment item XML into HTML.

Install

npm install qti-html-renderer

Usage

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 explanation

The 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-identifier equals a qti-response-declaration identifier, the interaction gets the matching declaration's values in full. A direct match is only honored when the identifier was carried by exactly one qti-response-declaration element in the XML. When two or more qti-response-declaration elements 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 RESPONSE distribution — applies only to the pure cloze shape it was designed for. ALL of the following must hold: there is exactly one qti-response-declaration element in the XML (this is a raw element count, not a Map.size check — two declarations sharing an identifier collapse to a single Map entry and would otherwise pass an equality test the XML does not actually satisfy); its identifier is exactly RESPONSE with cardinality="ordered" and base-type="string"; no interaction matches a declaration directly; every interaction in the item is unmatched; every interaction's published type is exactly text-entry (custom or non-standard interactions reported as other are excluded); the unmatched response-identifiers are exactly RESPONSE_1..RESPONSE_N in 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 and declarationValueIndex records the 0-based position assigned to each one. If any interaction matches a declaration directly — for example a literal RESPONSE interaction next to a RESPONSE_1 interaction — the fallback does not fire: the RESPONSE interaction wins by direct match and RESPONSE_1 stays 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 normalizePreBlocksenhanceCodeBlocksenhanceInlineCode 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-feedback

ExplanationRenderOptions:

  • 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 format

Release

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:

  1. Set the same version in package.json, package-lock.json, and CHANGELOG.md.
  2. Push the change to main and wait for the normal CI to pass.
  3. Create and push the matching v<version> annotated tag (for example v0.1.3).
  4. 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.

Maintenance and Policies