Skip to content

QTI Base Schema

@citolab/prose-qti/schema provides qtiBasicNodes and qtiBasicMarks — a drop-in replacement for importing prosemirror-schema-basic directly when you assemble a raw ProseMirror schema for a QTI host. It is also re-exported from the package root (@citolab/prose-qti).

When to use this

Use this package when you build your ProseMirror Schema by hand (see the TypeScript Integration guide) rather than assembling extensions through ProseKit. If you already use ProseKit, defineBasicExtension() from @citolab/prose-extensions/prosekit covers the same ground.

Why not prosemirror-schema-basic directly

  • Image dimensions. qtiBasicNodes.image preserves width and height attributes on parse and serialize. Plain prosemirror-schema-basic drops them, so QTI XML image dimensions are lost on import and re-export.
  • A single import path. Nodes and marks come from one module, so a host is not assembling its baseline from two sources and hoping they agree.

What it provides

  • qtiBasicNodesprosemirror-schema-basic’s node specs with the image node’s parseDOM/toDOM replaced to round-trip width/height, plus a qtiLayoutDiv node for the QTI layout wrappers.
  • qtiBasicMarksprosemirror-schema-basic’s marks, unchanged, re-exported for a single consistent import path.
  • qtiLayoutDivLockPlugin — an optional ProseMirror plugin that keeps qtiLayoutDiv wrappers from being added or removed by editing. Not included automatically; add it to your plugin list.
  • qtiPasteRescuePlugin — an optional ProseMirror plugin that fixes up a multi-block paste landing in a slot that only holds one block. Not included automatically; add it to your plugin list.

Nothing is removed from prosemirror-schema-basic — one node is overridden and one is added.

Layout wrappers (qtiLayoutDiv)

qtiBasicNodes.qtiLayoutDiv models the <div class="qti-layout-row"> / <div class="qti-layout-colN"> presentation wrappers that QTI items use for grid layout. They are author-written in the source XML — no editor offers a command to create one — so a schema that omits this node silently drops every wrapper on import.

Import and export preserve the wrapper’s class attribute exactly; editing the content inside a column, or changing a wrapper’s own class, both work normally.

Because nothing can author a new wrapper, an editor that lets a selection spanning one delete it leaves an author with structure they have no command to rebuild. qtiLayoutDivLockPlugin closes that gap:

import { qtiLayoutDivLockPlugin } from '@citolab/prose-qti/schema';
const plugins = [...otherPlugins, qtiLayoutDivLockPlugin];

It rejects any transaction that would change how many qtiLayoutDiv nodes the document contains, and leaves everything else — including re-classing a wrapper — untouched. It is a separate export rather than automatic, so a host that genuinely wants editable grids simply omits it from its plugin list.

Paste rescue (qtiPasteRescuePlugin)

Some interaction slots hold exactly one block — a choice’s text, a prompt, an associable choice’s content. Pasting two or more blocks into one of those has nowhere to go: ProseMirror’s own fitting logic closes out of the slot and reopens it for the remainder, which splits one choice (or one prompt) into two.

qtiPasteRescuePlugin rewrites a paste like that so it fits instead of splitting: a slot that repeats (qtiSimpleChoice+) gets one new sibling per pasted block, each with its own identifier; a slot that does not repeat (qtiPrompt?) gets every pasted block joined into the one slot it has. Which interactions and slots this applies to is not hardcoded — it asks the schema itself whether the target already accepts more than one block, so it keeps working as interactions are added or their content models change.

import { qtiPasteRescuePlugin } from '@citolab/prose-qti/schema';
const plugins = [...otherPlugins, qtiPasteRescuePlugin];

Order relative to a semantic-paste plugin (see ProseMirror Plugins) does not matter — that one runs on the clipboard HTML, this one runs on the already-parsed slice, and ProseMirror always runs the two stages in that order.

Typical usage

import { Schema } from 'prosemirror-model';
import { qtiBasicNodes, qtiBasicMarks } from '@citolab/prose-qti';
import { qtiChoiceInteractionNodeSpec } from '@citolab/prose-qti/components/choice';
export const appSchema = new Schema({
marks: qtiBasicMarks,
nodes: {
doc: { content: 'block+', attrs: { identifier: {}, title: {} } },
paragraph: { ...qtiBasicNodes.paragraph, content: 'inline*', group: 'block richtext' },
text: qtiBasicNodes.text,
image: qtiBasicNodes.image,
// QTI interactions
qtiChoiceInteraction: { ...qtiChoiceInteractionNodeSpec },
},
});

Only the node names you list in the nodes object end up in the schema — qtiBasicNodes is a plain lookup object, not a schema fragment applied automatically. Restate content/group for each node as your document topology requires, the same as for any other node spec in this repo (see the TypeScript Integration guide).

Notes

  • blockquote is included, along with horizontal_rule and code_block. An earlier note here claimed QTI item bodies do not model blockquote; that was wrong. The QTI 3 implementation guide’s own examples place <blockquote> inside <qti-item-body> (§3.2.3, §3.2.8), and <hr>, <pre> and <code> are permitted too. Dropping a node the format allows does not narrow a schema so much as silently discard an author’s markup, since ProseMirror’s parser omits what it cannot place rather than reporting it.
  • qtiBasicMarks is prosemirror-schema-basic’s marks object as-is — mark identity is unaffected, so strong/em/code/link are unchanged.