Moodle XML
Quiz Engine
Store questions in standard Moodle XML question banks. Your Jekyll site fetches, parses, shuffles, and serves them — statically, with zero backend.
How it works
Moodle XML is an open, widely supported format — any LMS, quiz authoring tool, or AI can generate it. Your Jekyll site treats these files as static assets (like images), so they live in assets/questions/ and are served by GitHub Pages unchanged. The quiz engine runs entirely in the browser: it fetches the XML, parses it with the native DOM API, picks a random subset of questions, shuffles answers, renders the UI, and scores responses.
Write .xml files in Moodle format. One file per topic/chapter. Contains as many questions as you want — the quiz only picks N of them.
One 300-line JavaScript file. Handles fetching, parsing, shuffling, rendering, scoring, and persistence for all 5 supported question types.
A single _includes/quiz.html takes parameters from the lesson's front matter and renders the quiz container. The engine does the rest.
How to export from real Moodle, recommended editors, and tips for writing questions with AI assistance.
Moodle XML format — the essentials
A Moodle XML question bank is a <quiz> element containing one or more <question> elements. Each question has a type attribute. The key rules:
| Element | Meaning |
|---|---|
<questiontext> | The question stem — can contain HTML (use CDATA) |
<answer fraction="100"> | A correct answer (100 = full credit, 0 = wrong) |
<answer fraction="50"> | Partially correct (for multi-select questions) |
<answer fraction="-33"> | Negatively scored wrong answer |
<feedback> | Per-answer explanation shown after selection |
<generalfeedback> | Shown after the question regardless of answer |
<single>true</single> | For multichoice: true = one answer, false = multiple |
<tolerance> | For numerical: acceptable ± margin |
<![CDATA[...]]> | Wraps HTML content — LaTeX like $\theta$ works inside |
$\theta_1$, $$M(q)\ddot{q}$$) will render automatically after the quiz is inserted into the DOM. You just need to call MathJax.typesetPromise() after rendering — the engine does this for you.Part 1 — Writing Question Banks
File structure
Create question bank files as static assets. Jekyll copies them to _site/ unchanged — GitHub Pages serves them like any other file.
questions/
robotics/
ch1-kinematics.xml # Chapter 1 question bank
ch2-dynamics.xml
ch3-trajectory.xml
control-systems/
pid-control.xml
state-space.xml
programming/
python-basics.xml
assets/js/
quiz-engine.js # The engine
_includes/
quiz.html # Updated include
assets/css/
quiz.css # Quiz styles
assets/questions/. Keep banks thematic — a single XML file should represent one coherent topic with 15–50 questions. The quiz picks a random subset each load.multichoice — single and multiple answer
The most common type. When <single>true</single>, exactly one answer is correct (fraction="100"). When false, multiple answers can be correct (fractions sum to 100).
xml<?xml version="1.0" encoding="UTF-8"?>
<quiz>
<!-- ─────────────────────────────────────────────────────
MULTICHOICE — single correct answer
───────────────────────────────────────────────────── -->
<question type="multichoice">
<name><text>FK definition</text></name>
<questiontext format="html">
<text><![CDATA[
<p>Given joint angles <strong>θ₁ = 45°</strong> and <strong>θ₂ = −30°</strong>
for a 2R planar robot with link lengths $L_1 = 1$ m, $L_2 = 0.8$ m,
what does Forward Kinematics compute?</p>
]]></text>
</questiontext>
<generalfeedback format="html">
<text><![CDATA[<p>FK maps joint space → task space:
$\mathbf{p} = f(\theta)$.</p>]]></text>
</generalfeedback>
<defaultgrade>1.0</defaultgrade>
<single>true</single> <!-- one correct answer -->
<shuffleanswers>1</shuffleanswers> <!-- engine will also shuffle -->
<!-- Correct answer: fraction="100" -->
<answer fraction="100" format="html">
<text>The position and orientation of the end-effector</text>
<feedback format="html">
<text><![CDATA[<p>✓ Correct. FK gives us
$(x, y, \phi)$ from $(\theta_1, \theta_2)$.</p>]]></text>
</feedback>
</answer>
<!-- Wrong answers: fraction="0" -->
<answer fraction="0" format="html">
<text>The joint torques required to reach a target</text>
<feedback><text>That is dynamics / inverse dynamics.</text></feedback>
</answer>
<answer fraction="0" format="html">
<text>The joint angles given the end-effector position</text>
<feedback><text>That is Inverse Kinematics (IK), the reverse problem.</text></feedback>
</answer>
<answer fraction="0" format="html">
<text>The velocity of the end-effector</text>
<feedback><text>Velocity is computed via the Jacobian, not FK directly.</text></feedback>
</answer>
</question>
<!-- ─────────────────────────────────────────────────────
MULTICHOICE — multiple correct answers
fraction values must sum to 100 for full credit.
Use negative fractions for penalties.
───────────────────────────────────────────────────── -->
<question type="multichoice">
<name><text>IK solution count</text></name>
<questiontext format="html">
<text><![CDATA[<p>Which of the following are valid solution
counts for the IK of a 2R planar robot?
<em>Select all that apply.</em></p>]]></text>
</questiontext>
<single>false</single> <!-- multiple answers -->
<!-- Two correct answers, each worth 50% -->
<answer fraction="50">
<text>0 solutions (target out of reach)</text>
<feedback><text>Yes — if the target is beyond the robot's workspace.</text></feedback>
</answer>
<answer fraction="50">
<text>2 solutions (elbow-up and elbow-down)</text>
<feedback><text>Yes — the typical case within the workspace.</text></feedback>
</answer>
<!-- Wrong answers with penalty -->
<answer fraction="-25">
<text>Exactly 3 solutions always</text>
<feedback><text>A 2R robot has at most 2 IK solutions.</text></feedback>
</answer>
<answer fraction="-25">
<text>Infinite solutions always</text>
<feedback><text>Infinite solutions occur in redundant robots (≥ 3 DOF for planar).</text></feedback>
</answer>
</question>
</quiz>
truefalse questions
A simplified multichoice with only two options. The fraction="100" goes on whichever answer is correct.
xml<question type="truefalse">
<name><text>Jacobian singularity</text></name>
<questiontext format="html">
<text><![CDATA[
<p>At a singularity, the Jacobian matrix $J(q)$ becomes
rank-deficient, causing the robot to lose one or more
degrees of controllable freedom.</p>
]]></text>
</questiontext>
<answer fraction="100"> <!-- TRUE is correct -->
<text>true</text>
<feedback><text>Correct. At singularities det(J) = 0 and motion in
certain directions becomes impossible or requires infinite torque.
</text></feedback>
</answer>
<answer fraction="0"> <!-- FALSE is wrong -->
<text>false</text>
<feedback><text>Incorrect. Singularities are a real and important
limitation in robot control.
</text></feedback>
</answer>
</question>
shortanswer — text input with pattern matching
The student types a text answer. Multiple answer patterns can be accepted (use * as wildcard). <usecase>0</usecase> = case-insensitive.
xml<question type="shortanswer">
<name><text>DOF acronym</text></name>
<questiontext format="html">
<text><![CDATA[
<p>What does <strong>DOF</strong> stand for in robotics?</p>
]]></text>
</questiontext>
<usecase>0</usecase> <!-- 0 = case insensitive -->
<!-- Accept multiple phrasings — wildcard * matches anything -->
<answer fraction="100">
<text>degrees of freedom</text>
<feedback><text>Correct!</text></feedback>
</answer>
<answer fraction="100">
<text>degree of freedom</text> <!-- singular also OK -->
<feedback><text>Correct!</text></feedback>
</answer>
<answer fraction="0">
<text>*</text> <!-- catch-all wrong answer -->
<feedback><text>Not quite. DOF = Degrees Of Freedom.</text></feedback>
</answer>
</question>
numerical — number input with tolerance
The student enters a number. You define the correct value and an acceptable margin (tolerance).
xml<question type="numerical">
<name><text>2R FK calculation</text></name>
<questiontext format="html">
<text><![CDATA[
<p>A 2R robot has $L_1 = L_2 = 1$ m. Both joints are at
$\theta_1 = \theta_2 = 0°$. What is the x-coordinate of the
end-effector (in metres)?</p>
]]></text>
</questiontext>
<answer fraction="100">
<text>2</text> <!-- correct value -->
<tolerance>0.01</tolerance> <!-- accept 1.99 – 2.01 -->
<feedback><text>Correct! x = L₁cos(0) + L₂cos(0) = 1 + 1 = 2 m.</text></feedback>
</answer>
<!-- Optional: partially credit close answers -->
<answer fraction="50">
<text>2</text>
<tolerance>0.1</tolerance> <!-- accept 1.9 – 2.1 for 50% -->
<feedback><text>Close, but be more precise.</text></feedback>
</answer>
<generalfeedback><text><![CDATA[
<p>Formula: $x = L_1\cos\theta_1 + L_2\cos(\theta_1+\theta_2)$</p>
]]></text></generalfeedback>
</question>
matching — pair terms with definitions
Each <subquestion> is one pair. The student matches left-column terms to right-column definitions via dropdowns.
xml<question type="matching">
<name><text>Control terms matching</text></name>
<questiontext format="html">
<text>Match each term to its correct definition.</text>
</questiontext>
<shuffleanswers>1</shuffleanswers>
<subquestion format="html">
<text>PID</text>
<answer><text>Proportional-Integral-Derivative controller</text></answer>
</subquestion>
<subquestion format="html">
<text>MPC</text>
<answer><text>Model Predictive Control — optimises over a receding horizon</text></answer>
</subquestion>
<subquestion format="html">
<text>Lyapunov stability</text>
<answer><text>A method to prove stability without solving differential equations</text></answer>
</subquestion>
<subquestion format="html">
<text>Jacobian matrix</text>
<answer><text>Maps joint velocities to end-effector velocities</text></answer>
</subquestion>
</question>
Authoring tips
Do
- Use
<![CDATA[...]]>whenever your text contains HTML or LaTeX - Write at least 4 distractors per multichoice question
- Put the explanation in
<feedback>not<generalfeedback>when it's answer-specific - Use
<name>as a unique identifier for debugging - Give wrong answers
fraction="0"notfraction="-0" - Test LaTeX renders:
$\theta$notθ
Don't
- Don't use
&,<etc. inside CDATA — it's already raw - Don't have only 1 wrong answer — too easy to guess
- Don't mix question types in one bank (OK technically, but harder to manage)
- Don't forget
<single>on multichoice — defaults to true - Don't use HTML tables in question text — hard to read on mobile
assets/questions/.Part 2 — The Quiz Engine
quiz-engine.js — design
The engine is a self-contained module. It exposes one public function: QuizEngine.init(config). Everything else is internal. Here's the data flow:
js// Config object passed by quiz.html include:
// {
// bankUrl: '/assets/questions/robotics/ch1-kinematics.xml',
// containerId: 'quiz-container-abc123',
// count: 5, // how many questions to pick
// shuffleQuestions: true,
// shuffleAnswers: true,
// passPercent: 70,
// lessonSlug: 'robotics/02-kinematics',
// lang: 'fr' // for UI strings
// }
// Internal flow:
// init(config)
// └─ fetch(bankUrl) → raw XML string
// └─ parseXML(xmlStr) → XMLDocument
// └─ extractQuestions(doc) → Question[]
// └─ selectRandom(qs, count) → Question[] (subset)
// └─ shuffleAll(qs) → shuffles qs and each q's answers
// └─ render(qs, container) → injects HTML into DOM
// └─ bindEvents(container) → click handlers
// └─ MathJax.typesetPromise → re-renders LaTeX
XML Parser
The browser's built-in DOMParser reads the Moodle XML — no library needed. Each question type has its own extraction function.
js// ── XML parsing helpers ──────────────────────────────────
/**
* getText(el, tagName) — safely read text from a child element.
* Returns '' if the element doesn't exist.
*/
function getText(el, tagName) {
const child = el.querySelector(tagName + ' > text');
return child ? child.textContent.trim() : '';
}
/**
* parseQuestions(xmlDoc) — convert XMLDocument → Question[]
* Handles: multichoice, truefalse, shortanswer, numerical, matching
*/
function parseQuestions(xmlDoc) {
const questions = [];
const nodes = xmlDoc.querySelectorAll('question');
nodes.forEach(function(q) {
const type = q.getAttribute('type');
if (!type || type === 'category') return; // skip category nodes
const base = {
type: type,
name: getText(q, 'name'),
text: getText(q, 'questiontext'),
generalFeedback: getText(q, 'generalfeedback'),
defaultGrade: parseFloat(getText(q, 'defaultgrade')) || 1,
};
if (type === 'multichoice' || type === 'truefalse') {
const single = getText(q, 'single') !== 'false'; // default true
const answers = [];
q.querySelectorAll('answer').forEach(function(a) {
const textEl = a.querySelector('text');
const fbEl = a.querySelector('feedback text');
answers.push({
text: textEl ? textEl.textContent.trim() : '',
fraction: parseFloat(a.getAttribute('fraction')) || 0,
feedback: fbEl ? fbEl.textContent.trim() : '',
});
});
questions.push({ ...base, single, answers });
} else if (type === 'shortanswer') {
const usecase = getText(q, 'usecase') === '1';
const answers = [];
q.querySelectorAll('answer').forEach(function(a) {
const textEl = a.querySelector('text');
const fbEl = a.querySelector('feedback text');
answers.push({
pattern: textEl ? textEl.textContent.trim() : '',
fraction: parseFloat(a.getAttribute('fraction')) || 0,
feedback: fbEl ? fbEl.textContent.trim() : '',
});
});
questions.push({ ...base, usecase, answers });
} else if (type === 'numerical') {
const answers = [];
q.querySelectorAll('answer').forEach(function(a) {
const textEl = a.querySelector('text');
const tolEl = a.querySelector('tolerance');
const fbEl = a.querySelector('feedback text');
answers.push({
value: parseFloat(textEl ? textEl.textContent : '0'),
tolerance: parseFloat(tolEl ? tolEl.textContent : '0'),
fraction: parseFloat(a.getAttribute('fraction')) || 0,
feedback: fbEl ? fbEl.textContent.trim() : '',
});
});
questions.push({ ...base, answers });
} else if (type === 'matching') {
const pairs = [];
q.querySelectorAll('subquestion').forEach(function(sq) {
const termEl = sq.querySelector('text');
const defEl = sq.querySelector('answer text');
if (termEl && defEl) {
pairs.push({
term: termEl.textContent.trim(),
definition: defEl.textContent.trim(),
});
}
});
questions.push({ ...base, pairs });
}
// 'essay' and unknown types are silently skipped
});
return questions;
}
Shuffle & select
js/**
* fisherYates(array) — in-place shuffle, returns the array.
* Uses crypto.getRandomValues for true randomness.
*/
function fisherYates(arr) {
for (let i = arr.length - 1; i > 0; i--) {
// crypto.getRandomValues gives a better distribution than Math.random()
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const j = buf[0] % (i + 1);
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/**
* selectAndShuffle(questions, config) — pick a random subset,
* then optionally shuffle answers within each question.
*/
function selectAndShuffle(questions, config) {
let pool = [...questions]; // copy so original is untouched
// Shuffle the question order
if (config.shuffleQuestions) fisherYates(pool);
// Take first N (or all if count >= pool length)
const count = Math.min(config.count || pool.length, pool.length);
pool = pool.slice(0, count);
// Shuffle answers within each question
if (config.shuffleAnswers) {
pool.forEach(function(q) {
if (q.answers) fisherYates(q.answers);
// For matching: shuffle the definitions column only (not the terms)
if (q.pairs) {
const defs = q.pairs.map(p => p.definition);
fisherYates(defs);
// Store shuffled definitions alongside original pairs
q._shuffledDefs = defs;
}
});
} else if (pool.some(q => q.pairs)) {
// Even without shuffleAnswers, matching needs _shuffledDefs initialised
pool.forEach(function(q) {
if (q.pairs) q._shuffledDefs = q.pairs.map(p => p.definition);
});
}
return pool;
}
Renderer — one function per question type
js// ── Renderer ─────────────────────────────────────────────
function renderQuestion(q, index, total) {
const qid = 'q' + index; // unique id per question in this quiz attempt
let body = '';
if (q.type === 'multichoice' || q.type === 'truefalse') {
const inputType = q.single !== false ? 'radio' : 'checkbox';
body = q.answers.map((a, ai) => `
<label class="quiz-choice-label" data-qid="${qid}" data-ai="${ai}"
data-fraction="${a.fraction}" data-feedback="${encodeURI(a.feedback)}">
<input type="${inputType}" name="${qid}" value="${ai}"
class="quiz-choice-input">
<span class="quiz-choice-text">${a.text}</span>
</label>`).join('');
} else if (q.type === 'shortanswer') {
body = `
<div class="quiz-shortanswer">
<input type="text" class="quiz-text-input form-control"
id="${qid}-input" placeholder="Type your answer…"
data-qid="${qid}"
data-answers="${encodeURI(JSON.stringify(q.answers))}"
data-usecase="${q.usecase ? 1 : 0}">
<button class="btn btn-sm btn-outline-dark mt-2 quiz-submit-text"
data-qid="${qid}">Submit</button>
</div>`;
} else if (q.type === 'numerical') {
body = `
<div class="quiz-numerical">
<input type="number" step="any" class="quiz-num-input form-control"
id="${qid}-input" placeholder="Enter a number…"
data-qid="${qid}"
data-answers="${encodeURI(JSON.stringify(q.answers))}">
<button class="btn btn-sm btn-outline-dark mt-2 quiz-submit-num"
data-qid="${qid}">Submit</button>
</div>`;
} else if (q.type === 'matching') {
const allDefs = q._shuffledDefs;
const rows = q.pairs.map((pair, pi) => {
const opts = ['<option value="">— choose —</option>',
...allDefs.map(d => `<option value="${encodeURI(d)}">${d}</option>`)
].join('');
return `<tr>
<td class="quiz-match-term">${pair.term}</td>
<td><select class="form-control form-control-sm quiz-match-select"
data-qid="${qid}" data-pi="${pi}"
data-correct="${encodeURI(pair.definition)}">${opts}</select></td>
</tr>`;
}).join('');
body = `
<table class="quiz-match-table table table-sm">
<tbody>${rows}</tbody>
</table>
<button class="btn btn-sm btn-outline-dark quiz-submit-match"
data-qid="${qid}">Submit</button>`;
}
return `
<div class="quiz-question" id="${qid}" data-type="${q.type}"
data-answered="false"
data-gfeedback="${encodeURI(q.generalFeedback)}">
<p class="quiz-q-header">
<span class="quiz-q-num">${index + 1} / ${total}</span>
<span class="quiz-q-type badge badge-light ml-1">${q.type}</span>
</p>
<div class="quiz-q-text">${q.text}</div>
<div class="quiz-choices">${body}</div>
<div class="quiz-feedback-box" style="display:none"></div>
<div class="quiz-gfeedback-box" style="display:none"></div>
</div>`;
}
Scoring logic
js// ── Scoring ───────────────────────────────────────────────
/**
* scoreMultichoice — handles both single and multiple-answer.
* For single: 100% if correct answer selected, 0 otherwise.
* For multiple: sum of fractions of selected answers, clamped [0, 1].
*/
function scoreMultichoice(qEl, q) {
const selected = [...qEl.querySelectorAll('input:checked')]
.map(el => parseInt(el.value));
if (selected.length === 0) return null; // not answered yet
// Highlight each choice: green if correct, red if wrong
qEl.querySelectorAll('.quiz-choice-label').forEach(function(lbl) {
const ai = parseInt(lbl.dataset.ai);
const frac= parseFloat(lbl.dataset.fraction);
const fb = decodeURI(lbl.dataset.feedback);
const isSelected = selected.includes(ai);
const isCorrect = frac > 0;
lbl.classList.add(
isCorrect ? 'quiz-correct' : (isSelected ? 'quiz-wrong' : 'quiz-neutral')
);
const inp = lbl.querySelector('input');
if (inp) inp.disabled = true;
// Show feedback for selected answers (or the correct answer if wrong)
if (fb && (isSelected || isCorrect)) {
const fbEl = document.createElement('small');
fbEl.className = 'quiz-answer-feedback';
fbEl.innerHTML = fb;
lbl.appendChild(fbEl);
}
});
// Calculate score: sum fractions of selected answers
let totalFrac = 0;
selected.forEach(function(ai) {
totalFrac += q.answers[ai]?.fraction || 0;
});
return Math.max(0, Math.min(100, totalFrac)) / 100; // normalise to [0,1]
}
/**
* scoreShortAnswer — pattern matching with wildcard support.
* Pattern "*" matches anything. "*text*" contains match.
*/
function scoreShortAnswer(qEl, q) {
const inp = qEl.querySelector('.quiz-text-input');
if (!inp) return null;
let val = inp.value.trim();
if (!val) return null;
const answers = JSON.parse(decodeURI(inp.dataset.answers));
const cs = inp.dataset.usecase === '1';
if (!cs) val = val.toLowerCase();
let matchedFrac = 0, matchedFeedback = '';
for (const a of answers) {
let pat = cs ? a.pattern : a.pattern.toLowerCase();
// Convert Moodle wildcard pattern to regex:
// '*' → '.*' (matches anything), escape other regex chars
const regex = new RegExp(
'^' + pat.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*') + '$'
);
if (regex.test(val)) {
matchedFrac = a.fraction;
matchedFeedback = a.feedback;
break;
}
}
inp.disabled = true;
inp.classList.add(matchedFrac > 0 ? 'quiz-input-correct' : 'quiz-input-wrong');
const fbBox = qEl.querySelector('.quiz-feedback-box');
fbBox.style.display = 'block';
fbBox.className = 'quiz-feedback-box alert ' + (matchedFrac > 0 ? 'alert-success' : 'alert-warning');
fbBox.innerHTML = (matchedFrac > 0 ? '✓ ' : '✗ ') + (matchedFeedback || 'Try again.');
return Math.max(0, matchedFrac) / 100;
}
/**
* scoreNumerical — checks if value falls within tolerance range.
* Tries answers in order (most precise first = highest fraction first).
*/
function scoreNumerical(qEl, q) {
const inp = qEl.querySelector('.quiz-num-input');
if (!inp || inp.value === '') return null;
const val = parseFloat(inp.value);
const answers = JSON.parse(decodeURI(inp.dataset.answers));
// Sort by fraction descending — best answer wins if multiple match
answers.sort((a, b) => b.fraction - a.fraction);
let matchedFrac = 0, matchedFb = '';
for (const a of answers) {
if (Math.abs(val - a.value) <= a.tolerance) {
matchedFrac = a.fraction;
matchedFb = a.feedback;
break;
}
}
inp.disabled = true;
inp.classList.add(matchedFrac > 0 ? 'quiz-input-correct' : 'quiz-input-wrong');
const fbBox = qEl.querySelector('.quiz-feedback-box');
fbBox.style.display = 'block';
fbBox.className = 'quiz-feedback-box alert ' + (matchedFrac > 0 ? 'alert-success' : 'alert-danger');
fbBox.innerHTML = (matchedFrac > 0 ? '✓ ' : '✗ Incorrect. ') + matchedFb;
return Math.max(0, matchedFrac) / 100;
}
/**
* scoreMatching — one point per correct pair.
*/
function scoreMatching(qEl) {
const selects = [...qEl.querySelectorAll('.quiz-match-select')];
if (selects.some(s => !s.value)) return null; // incomplete
let correct = 0;
selects.forEach(function(sel) {
const chosen = decodeURI(sel.value);
const expected = decodeURI(sel.dataset.correct);
const isCorrect = chosen === expected;
sel.disabled = true;
sel.style.borderColor = isCorrect ? '#28a745' : '#dc3545';
if (isCorrect) correct++;
else {
// Show correct answer as hint
const hint = document.createElement('small');
hint.className = 'text-danger d-block';
hint.textContent = '→ ' + expected;
sel.parentNode.appendChild(hint);
}
});
return correct / selects.length; // fraction of pairs correct
}
localStorage persistence
js// ── Persistence ───────────────────────────────────────────
function saveQuizResult(lessonSlug, score, total, passed) {
const key = 'quiz_result_' + lessonSlug;
const data = {
score: score,
total: total,
percent: Math.round(score / total * 100),
passed: passed,
timestamp: new Date().toISOString(),
};
localStorage.setItem(key, JSON.stringify(data));
// Also mark lesson as done if passed (integrates with progress.js)
if (passed) localStorage.setItem('lesson_done_' + lessonSlug, '1');
}
function loadQuizResult(lessonSlug) {
try {
return JSON.parse(localStorage.getItem('quiz_result_' + lessonSlug));
} catch(e) { return null; }
}
Full quiz-engine.js — complete file
Put everything together into assets/js/quiz-engine.js. This is the complete, production-ready file:
js/**
* quiz-engine.js — Moodle XML Quiz Engine for Jekyll
* Supports: multichoice, truefalse, shortanswer, numerical, matching
* Usage: QuizEngine.init(config) — called by _includes/quiz.html
*/
(function() {
'use strict';
// ─── Utilities ────────────────────────────────────────────────
function getText(el, sel) {
const c = el.querySelector(sel + ' > text');
return c ? c.textContent.trim() : '';
}
function fisherYates(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const j = buf[0] % (i + 1);
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// ─── XML Parser ────────────────────────────────────────────────
function parseQuestions(xmlDoc) {
const questions = [];
xmlDoc.querySelectorAll('question').forEach(function(q) {
const type = q.getAttribute('type');
if (!type || type === 'category' || type === 'essay') return;
const base = {
type: type,
name: getText(q, 'name'),
text: getText(q, 'questiontext'),
generalFeedback: getText(q, 'generalfeedback'),
defaultGrade: parseFloat(getText(q, 'defaultgrade')) || 1,
};
if (type === 'multichoice' || type === 'truefalse') {
const single = getText(q, 'single') !== 'false';
const answers = [...q.querySelectorAll('answer')].map(a => ({
text: (a.querySelector('text')||{}).textContent?.trim()||'',
fraction: parseFloat(a.getAttribute('fraction'))||0,
feedback: (a.querySelector('feedback text')||{}).textContent?.trim()||'',
}));
questions.push({...base, single, answers});
} else if (type === 'shortanswer') {
const usecase = (q.querySelector('usecase')||{}).textContent === '1';
const answers = [...q.querySelectorAll('answer')].map(a => ({
pattern: (a.querySelector('text')||{}).textContent?.trim()||'',
fraction: parseFloat(a.getAttribute('fraction'))||0,
feedback: (a.querySelector('feedback text')||{}).textContent?.trim()||'',
}));
questions.push({...base, usecase, answers});
} else if (type === 'numerical') {
const answers = [...q.querySelectorAll('answer')].map(a => ({
value: parseFloat((a.querySelector('text')||{}).textContent||'0'),
tolerance: parseFloat((a.querySelector('tolerance')||{}).textContent||'0'),
fraction: parseFloat(a.getAttribute('fraction'))||0,
feedback: (a.querySelector('feedback text')||{}).textContent?.trim()||'',
}));
questions.push({...base, answers});
} else if (type === 'matching') {
const pairs = [];
q.querySelectorAll('subquestion').forEach(function(sq) {
const t = sq.querySelector('text');
const d = sq.querySelector('answer text');
if (t && d) pairs.push({ term: t.textContent.trim(), definition: d.textContent.trim() });
});
questions.push({...base, pairs});
}
});
return questions;
}
// ─── Select & Shuffle ─────────────────────────────────────────
function selectAndShuffle(questions, cfg) {
let pool = [...questions];
if (cfg.shuffleQuestions) fisherYates(pool);
pool = pool.slice(0, Math.min(cfg.count || pool.length, pool.length));
pool.forEach(function(q) {
if (cfg.shuffleAnswers && q.answers) fisherYates(q.answers);
if (q.pairs) {
q._shuffledDefs = q.pairs.map(p => p.definition);
if (cfg.shuffleAnswers) fisherYates(q._shuffledDefs);
}
});
return pool;
}
// ─── Renderer ─────────────────────────────────────────────────
function renderQuestion(q, i, total) {
const id = 'q' + i;
let body = '';
if (q.type === 'multichoice' || q.type === 'truefalse') {
const t = q.single !== false ? 'radio' : 'checkbox';
body = q.answers.map((a, ai) => `
<label class="quiz-choice-label" data-ai="${ai}" data-fraction="${a.fraction}"
data-feedback="${encodeURI(a.feedback)}">
<input type="${t}" name="${id}" value="${ai}" class="quiz-choice-input">
<span class="quiz-choice-text">${a.text}</span>
</label>`).join('');
} else if (q.type === 'shortanswer') {
body = `<div class="quiz-shortanswer">
<input type="text" class="quiz-text-input form-control" id="${id}-input"
placeholder="Type your answer…"
data-answers="${encodeURI(JSON.stringify(q.answers))}"
data-usecase="${q.usecase?1:0}">
<button class="btn btn-sm btn-outline-dark mt-2 quiz-submit-text" data-qid="${id}">Submit</button>
</div>`;
} else if (q.type === 'numerical') {
body = `<div class="quiz-numerical">
<input type="number" step="any" class="quiz-num-input form-control" id="${id}-input"
placeholder="Enter a number…"
data-answers="${encodeURI(JSON.stringify(q.answers))}">
<button class="btn btn-sm btn-outline-dark mt-2 quiz-submit-num" data-qid="${id}">Submit</button>
</div>`;
} else if (q.type === 'matching') {
const rows = q.pairs.map((p, pi) => {
const opts = ['<option value="">— choose —</option>',
...q._shuffledDefs.map(d => `<option value="${encodeURI(d)}">${d}</option>`)].join('');
return `<tr><td class="quiz-match-term">${p.term}</td>
<td><select class="form-control form-control-sm quiz-match-select"
data-pi="${pi}" data-correct="${encodeURI(p.definition)}">${opts}</select></td></tr>`;
}).join('');
body = `<table class="table table-sm quiz-match-table"><tbody>${rows}</tbody></table>
<button class="btn btn-sm btn-outline-dark quiz-submit-match" data-qid="${id}">Submit</button>`;
}
return `<div class="quiz-question" id="${id}" data-type="${q.type}" data-answered="false"
data-grade="${q.defaultGrade}" data-gfeedback="${encodeURI(q.generalFeedback)}">
<p class="quiz-q-header">
<span class="quiz-q-num">${i+1} / ${total}</span>
<span class="badge badge-light ml-1 quiz-q-type">${q.type}</span>
</p>
<div class="quiz-q-text">${q.text}</div>
<div class="quiz-choices">${body}</div>
<div class="quiz-feedback-box" style="display:none"></div>
<div class="quiz-gfeedback-box" style="display:none"></div>
</div>`;
}
// ─── Scoring ──────────────────────────────────────────────────
function scoreQuestion(qEl, q) {
if (q.type === 'multichoice' || q.type === 'truefalse') {
const sel = [...qEl.querySelectorAll('input:checked')].map(e => parseInt(e.value));
if (!sel.length) return null;
qEl.querySelectorAll('.quiz-choice-label').forEach(function(lbl, ai) {
const frac = parseFloat(lbl.dataset.fraction);
const fb = decodeURI(lbl.dataset.feedback);
const chose = sel.includes(ai);
lbl.classList.add(frac > 0 ? 'quiz-correct' : (chose ? 'quiz-wrong' : 'quiz-neutral'));
(lbl.querySelector('input')||{}).disabled = true;
if (fb && (chose || frac > 0)) {
const s = document.createElement('small');
s.className = 'quiz-answer-feedback d-block mt-1';
s.innerHTML = fb; lbl.appendChild(s);
}
});
const tot = sel.reduce((s, ai) => s + (q.answers[ai]?.fraction||0), 0);
return Math.max(0, Math.min(100, tot)) / 100;
}
if (q.type === 'shortanswer') {
const inp = qEl.querySelector('.quiz-text-input');
if (!inp || !inp.value.trim()) return null;
let val = inp.value.trim();
const answers = JSON.parse(decodeURI(inp.dataset.answers));
const cs = inp.dataset.usecase === '1';
if (!cs) val = val.toLowerCase();
let frac = 0, fb = '';
for (const a of answers) {
const r = new RegExp('^'+(cs?a.pattern:a.pattern.toLowerCase())
.replace(/[.+?^${}()|[\]\\]/g,'\\$&').replace(/\*/g,'.*')+'$');
if (r.test(val)) { frac=a.fraction; fb=a.feedback; break; }
}
inp.disabled=true; inp.classList.add(frac>0?'quiz-input-correct':'quiz-input-wrong');
const b=qEl.querySelector('.quiz-feedback-box');
b.style.display='block'; b.className='quiz-feedback-box alert '+(frac>0?'alert-success':'alert-warning');
b.innerHTML=(frac>0?'✓ ':'✗ ')+(fb||'Try again.');
return Math.max(0,frac)/100;
}
if (q.type === 'numerical') {
const inp=qEl.querySelector('.quiz-num-input');
if (!inp||inp.value==='') return null;
const v=parseFloat(inp.value);
const ans=JSON.parse(decodeURI(inp.dataset.answers)).sort((a,b)=>b.fraction-a.fraction);
let frac=0,fb='';
for (const a of ans) { if (Math.abs(v-a.value)<=a.tolerance){frac=a.fraction;fb=a.feedback;break;} }
inp.disabled=true; inp.classList.add(frac>0?'quiz-input-correct':'quiz-input-wrong');
const b=qEl.querySelector('.quiz-feedback-box');
b.style.display='block'; b.className='quiz-feedback-box alert '+(frac>0?'alert-success':'alert-danger');
b.innerHTML=(frac>0?'✓ ':'✗ Incorrect. ')+fb;
return Math.max(0,frac)/100;
}
if (q.type === 'matching') {
const sels=[...qEl.querySelectorAll('.quiz-match-select')];
if (sels.some(s=>!s.value)) return null;
let ok=0;
sels.forEach(function(s){
const correct=decodeURI(s.value)===decodeURI(s.dataset.correct);
s.disabled=true; s.style.borderColor=correct?'#28a745':'#dc3545';
if(correct)ok++;
else{ const h=document.createElement('small'); h.className='text-danger d-block'; h.textContent='→ '+decodeURI(s.dataset.correct); s.parentNode.appendChild(h); }
});
return ok/sels.length;
}
return 0;
}
// ─── Results Panel ────────────────────────────────────────────
function showResults(container, totalScore, maxScore, cfg) {
const pct = Math.round(totalScore / maxScore * 100);
const passed = pct >= (cfg.passPercent || 70);
const panel = container.querySelector('#quiz-results');
const icon = passed ? '🎉' : '📚';
const cls = passed ? 'alert-success' : 'alert-warning';
panel.className = 'alert mt-4 ' + cls;
panel.innerHTML = `
<h5>${icon} Score: ${pct}% (${totalScore.toFixed(1)} / ${maxScore})</h5>
<p class="mb-1">${passed ? 'Excellent work! You passed.' : `Aim for ${cfg.passPercent||70}% to pass.`}</p>
<div class="progress mt-2" style="height:8px">
<div class="progress-bar ${passed?'bg-success':'bg-warning'}"
style="width:${pct}%"></div>
</div>
<button class="btn btn-sm btn-outline-dark mt-3 quiz-retry-btn">Try Again</button>
`;
panel.style.display = 'block';
// Save to localStorage
if (cfg.lessonSlug) {
try {
localStorage.setItem('quiz_result_'+cfg.lessonSlug,
JSON.stringify({pct, passed, date: new Date().toISOString()}));
if (passed) localStorage.setItem('lesson_done_'+cfg.lessonSlug, '1');
} catch(e) {}
}
}
// ─── Public API ───────────────────────────────────────────────
window.QuizEngine = {
init: async function(cfg) {
const container = document.getElementById(cfg.containerId);
if (!container) return;
// Show loading state
container.innerHTML = `
<div class="quiz-loading text-center py-4">
<div class="spinner-border text-success" role="status"></div>
<p class="mt-2 text-muted">Loading questions…</p>
</div>`;
let questions;
try {
const resp = await fetch(cfg.bankUrl);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const xmlStr = await resp.text();
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml');
// Check for parse errors
if (doc.querySelector('parsererror')) throw new Error('XML parse error');
questions = selectAndShuffle(parseQuestions(doc), cfg);
} catch(err) {
container.innerHTML = `<div class="alert alert-danger">
Failed to load questions: ${err.message}
<br><small>Bank: ${cfg.bankUrl}</small></div>`;
return;
}
if (!questions.length) {
container.innerHTML = '<div class="alert alert-warning">No questions found in this bank.</div>';
return;
}
// Build HTML
const html = `
<div class="quiz-header mb-3">
<span class="badge badge-success">${questions.length} questions</span>
<span class="text-muted small ml-2">Select answers, then click Submit.</span>
</div>
${questions.map((q, i) => renderQuestion(q, i, questions.length)).join('')}
<div id="quiz-results" style="display:none"></div>
<div class="quiz-submit-row mt-4">
<button class="btn btn-success quiz-submit-all">Submit All</button>
<span class="quiz-answered-count text-muted ml-3 small">0 / ${questions.length} answered</span>
</div>`;
container.innerHTML = html;
// Track state
const state = { scores: new Array(questions.length).fill(null), submitted: false };
function updateCount() {
const done = state.scores.filter(s => s !== null).length;
container.querySelector('.quiz-answered-count').textContent = done + ' / ' + questions.length + ' answered';
}
function submitQuestion(qEl, i) {
if (qEl.dataset.answered === 'true') return;
const s = scoreQuestion(qEl, questions[i]);
if (s === null) { alert('Please answer this question first.'); return; }
state.scores[i] = s;
qEl.dataset.answered = 'true';
// Show general feedback
const gf = decodeURI(qEl.dataset.gfeedback);
if (gf) {
const b = qEl.querySelector('.quiz-gfeedback-box');
b.style.display='block'; b.className='quiz-gfeedback-box alert alert-info mt-2 small'; b.innerHTML=gf;
}
updateCount();
// Re-typeset MathJax if available
if (window.MathJax?.typesetPromise) window.MathJax.typesetPromise([qEl]);
}
// Bind multichoice: auto-submit on selection if single
questions.forEach(function(q, i) {
const qEl = container.querySelector('#q'+i);
if (q.type === 'multichoice' || q.type === 'truefalse') {
if (q.single !== false) {
// Single-answer: reveal on click
qEl.querySelectorAll('.quiz-choice-input').forEach(function(inp) {
inp.addEventListener('change', () => submitQuestion(qEl, i));
});
}
// Multiple-answer: needs explicit Submit All
}
});
// Text submit buttons
container.querySelectorAll('.quiz-submit-text,.quiz-submit-num').forEach(function(btn) {
btn.addEventListener('click', function() {
const qEl = btn.closest('.quiz-question');
const idx = questions.findIndex((q,i) => container.querySelector('#q'+i) === qEl);
btn.disabled = true;
submitQuestion(qEl, idx);
});
});
// Matching submit buttons
container.querySelectorAll('.quiz-submit-match').forEach(function(btn) {
btn.addEventListener('click', function() {
const qEl = btn.closest('.quiz-question');
const idx = questions.findIndex((q,i) => container.querySelector('#q'+i) === qEl);
const s = scoreQuestion(qEl, questions[idx]);
if (s === null) { alert('Please fill all dropdowns.'); return; }
state.scores[idx] = s; qEl.dataset.answered='true'; btn.disabled=true;
updateCount();
});
});
// Submit All button
container.querySelector('.quiz-submit-all').addEventListener('click', function() {
if (state.submitted) return;
// Submit any remaining unanswered questions
questions.forEach(function(q, i) {
const qEl = container.querySelector('#q'+i);
if (qEl.dataset.answered !== 'true') {
const s = scoreQuestion(qEl, q);
state.scores[i] = s ?? 0;
qEl.dataset.answered = 'true';
}
});
state.submitted = true;
this.disabled = true;
const totalScore = state.scores.reduce((s, v) => s + (v||0), 0);
const maxScore = questions.length; // each worth 1 point
showResults(container, totalScore, maxScore, cfg);
if (window.MathJax?.typesetPromise) window.MathJax.typesetPromise([container]);
});
// Retry button (added to results panel dynamically)
container.addEventListener('click', function(e) {
if (e.target.classList.contains('quiz-retry-btn')) {
window.QuizEngine.init(cfg); // re-init = new random subset
}
});
// Initial MathJax typeset
if (window.MathJax?.typesetPromise) window.MathJax.typesetPromise([container]);
}
};
})(); // end IIFE
Part 3 — Jekyll Integration
_includes/quiz.html — the bridge
This include reads the quiz config from the lesson's front matter and passes it to the engine. Replace your existing _includes/quiz.html entirely:
liquid<!-- _includes/quiz.html
Usage: {%- include quiz.html -%}
Reads: page.quiz front matter block -->
{%- if page.quiz -%}
{%- assign q = page.quiz -%}
{%- assign quiz_id = page.slug | default: page.title | slugify | prepend: "quiz-" -%}
{%- assign base_url = site.baseurl | default: "" -%}
{%- assign t = site.data.i18n[page.lang | default: site.default_lang] -%}
<!-- Load the quiz engine once per page -->
{%- unless page.quiz_engine_loaded -%}
<link rel="stylesheet" href="{{base_url}}/assets/css/quiz.css">
<script src="{{base_url}}/assets/js/quiz-engine.js" defer></script>
{%- endunless -%}
<div class="quiz-wrapper my-5">
<div class="quiz-wrapper-header">
<h5 class="font-weight-bold">
{%- if t -%}{{ t.quiz_title }}{%- else -%}📝 Knowledge Check{%- endif -%}
</h5>
<small class="text-muted">
{%- if q.count and q.count < 999 -%}
{{ q.count }} questions randomly selected
{%- endif -%}
{%- if q.pass_percent -%} · Pass: {{ q.pass_percent }}%{%- endif -%}
</small>
</div>
<div id="{{ quiz_id }}" class="quiz-container">
<!-- Engine fills this -->
</div>
</div>
<script>
// Run after quiz-engine.js is loaded (defer attribute handles ordering)
document.addEventListener('DOMContentLoaded', function() {
window.QuizEngine.init({
containerId: '{{ quiz_id }}',
bankUrl: '{{ base_url }}/assets/questions/{{ q.bank }}',
count: {{ q.count | default: 999 }},
shuffleQuestions: {{ q.shuffle_questions | default: true }},
shuffleAnswers: {{ q.shuffle_answers | default: true }},
passPercent: {{ q.pass_percent | default: 70 }},
lessonSlug: '{{ page.course | default: "" }}/{{ page.slug | default: page.title | slugify }}',
lang: '{{ page.lang | default: site.default_lang }}',
});
});
</script>
{%- endif -%}
Lesson front matter — all options
yaml---
layout: lesson
title: "Forward & Inverse Kinematics"
course: robotics
lesson_order: 2
lang: en
# ── Quiz configuration ─────────────────────────────────────
quiz:
# Path relative to assets/questions/
bank: robotics/ch1-kinematics.xml
# How many questions to pick from the bank (omit = all)
count: 5
# Shuffle question order on each load? (default: true)
shuffle_questions: true
# Shuffle answer choices on each load? (default: true)
shuffle_answers: true
# Minimum score to pass and mark lesson complete (default: 70)
pass_percent: 70
---
## Introduction
Your lesson content here...
{%- include quiz.html -%}
## Next steps...
You can also place multiple quizzes in one lesson by calling the
include multiple times with different bank configs — but then you
need separate include files (quiz-mid.html, quiz-end.html) each
with their own front matter variable name.
_includes/quiz-mid.html and _includes/quiz-end.html that read from page.quiz_mid and page.quiz_end respectively. The engine supports multiple instances on the same page since each has a unique container ID.assets/css/quiz.css — complete styles
css/* assets/css/quiz.css — unified with your token system */
.quiz-wrapper {
border: 1px solid var(--border, #e9ecef);
border-radius: 8px;
overflow: hidden;
}
.quiz-wrapper-header {
background: var(--bg-subtle, #f8f9fa);
border-bottom: 1px solid var(--border, #e9ecef);
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.quiz-container { padding: 20px; }
/* ── Question layout ── */
.quiz-question {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border, #e9ecef);
}
.quiz-question:last-of-type { border-bottom: none; margin-bottom: 0; }
.quiz-q-header {
display: flex; align-items: center;
margin-bottom: 10px;
}
.quiz-q-num {
font-size: 0.72rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-secondary, #6c757d);
}
.quiz-q-type { font-size: 0.68rem; }
.quiz-q-text {
font-size: 1rem; line-height: 1.6;
margin-bottom: 16px;
color: var(--text-primary, #212529);
}
/* ── Multichoice ── */
.quiz-choice-label {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 14px;
border: 1px solid var(--border, #dee2e6);
border-radius: 6px; margin-bottom: 8px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
background: var(--surface, #fff);
}
.quiz-choice-label:hover {
border-color: var(--accent, #03a87c);
background: var(--accent-light, #e8f3ec);
}
.quiz-choice-input { margin-top: 3px; flex-shrink: 0; }
.quiz-choice-text { flex: 1; line-height: 1.5; }
/* ── Answer states ── */
.quiz-correct {
border-color: #28a745 !important;
background: rgba(40,167,69,0.08) !important;
}
.quiz-wrong {
border-color: #dc3545 !important;
background: rgba(220,53,69,0.08) !important;
}
.quiz-neutral { opacity: 0.55; }
.quiz-answer-feedback {
color: #495057; font-size: 0.82rem;
margin-top: 6px; font-style: italic;
}
/* ── Text / numerical inputs ── */
.quiz-text-input, .quiz-num-input {
max-width: 400px;
border-color: var(--border, #ced4da);
background: var(--surface, #fff);
color: var(--text-primary, #212529);
}
.quiz-input-correct { border-color: #28a745 !important; }
.quiz-input-wrong { border-color: #dc3545 !important; }
/* ── Matching table ── */
.quiz-match-table { margin-bottom: 12px; }
.quiz-match-term {
font-weight: 600;
vertical-align: middle;
padding-right: 16px;
color: var(--text-primary, #212529);
}
/* ── Submit row ── */
.quiz-submit-row {
display: flex; align-items: center;
padding-top: 16px;
border-top: 1px solid var(--border, #e9ecef);
}
/* ── Loading spinner ── */
.quiz-loading { padding: 40px 20px; }
/* ── Dark mode ── */
[data-theme="dark"] .quiz-choice-label {
background: var(--surface, #1a1f2e);
border-color: var(--border, #2a3045);
color: var(--text-primary, #e8eaed);
}
[data-theme="dark"] .quiz-choice-label:hover {
border-color: var(--accent, #05d4a0);
background: var(--accent-light, #0a2e24);
}
[data-theme="dark"] .quiz-text-input,
[data-theme="dark"] .quiz-num-input {
background: var(--surface, #1a1f2e);
border-color: var(--border, #2a3045);
color: var(--text-primary, #e8eaed);
}
Day-to-day authoring workflow
- Write or generate questions in Moodle XML → save to
assets/questions/<topic>/<name>.xml - In the lesson front matter, set
quiz.bank:to the relative path - Set
quiz.count: 5(or however many you want shown per attempt) - Place
{%- include quiz.html -%}anywhere in the lesson Markdown - Run
bundle exec jekyll serve— the quiz loads and shuffles on each page refresh - To update questions: edit the XML file. No rebuild needed for local dev (Jekyll hot-reloads static assets). On GitHub Pages: push the commit.
fetch() against file:// URLs is blocked by the browser. Always test via bundle exec jekyll serve (which runs on http://localhost:4000), not by opening the HTML file directly. On GitHub Pages the fetch works fine since everything is served from the same origin.Part 4 — Tools & Tips
Exporting from a real Moodle instance
- In Moodle: go to Question bank → select questions → Export
- Choose Moodle XML format
- Download the
.xmlfile - Place it in
assets/questions/— no conversion needed - The engine handles all Moodle-generated quirks (CDATA wrappers, extra whitespace, empty category nodes)
Recommended authoring tools
| Tool | Best for | Notes |
|---|---|---|
| Any text editor | Quick questions | Just follow the XML structure above |
| Moodle GIFT format + converter | Fast authoring | GIFT is simpler syntax; convert to XML with gift2moodle |
| H5P Question Set | Visual editor | Export as Moodle XML from H5P.org |
| AI (Claude/ChatGPT) | Bulk generation | Paste the XML schema above and ask for 10 questions |
| Moodle itself | Team collaboration | Build in Moodle, export XML, commit to repo |
Validate your XML before pushing
bash# Check XML is well-formed (catches unclosed tags etc.)
xmllint --noout assets/questions/robotics/ch1-kinematics.xml
# Output: nothing = valid. "parser error" = fix your XML.
# Count questions in a bank
grep -c '<question type=' assets/questions/robotics/ch1-kinematics.xml
"Generate 8 Moodle XML questions about [TOPIC]. Requirements: all multichoice type, 1 correct answer (fraction=100), 3 distractors (fraction=0), CDATA wrappers on questiontext, LaTeX equations where relevant using $...$. Per-answer feedback required. Output only the XML <question> elements (no <quiz> wrapper)."
Then wrap the output in
<?xml version="1.0" encoding="UTF-8"?><quiz>...</quiz>.Final checklist
Files to create
- At least one
assets/questions/<topic>/<name>.xmlquestion bank assets/js/quiz-engine.js— the complete engine from Step 2.7assets/css/quiz.css— styles from Step 3.3_includes/quiz.html— bridge from Step 3.1
Lesson front matter
- Add
quiz:block withbank:,count:,pass_percent: - Place
{%- include quiz.html -%}in the lesson Markdown
Validate
- Run
xmllint --noouton each XML bank before pushing - Test locally with
bundle exec jekyll serve(not via file://) - Open browser DevTools → Network tab → verify the XML file loads with HTTP 200
- Check Console for any parse errors
- Verify MathJax renders LaTeX in question text
- Test Retry button — should produce a different random subset
- Test localStorage: complete a quiz, reload page — past result should be remembered by
progress.js