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.

Moodle XML 3.x Vanilla JS 5 question types Random subset Fisher-Yates shuffle localStorage scores

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.

Part 1 — Question Banks

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.

Part 2 — The Engine (quiz-engine.js)

One 300-line JavaScript file. Handles fetching, parsing, shuffling, rendering, scoring, and persistence for all 5 supported question types.

Part 3 — Jekyll Integration

A single _includes/quiz.html takes parameters from the lesson's front matter and renders the quiz container. The engine does the rest.

Part 4 — Tooling

How to export from real Moodle, recommended editors, and tips for writing questions with AI assistance.

Why Moodle XML? It's the lingua franca of e-learning. You can: (a) write questions directly in any text editor, (b) export them from a real Moodle instance, (c) import them back into Moodle later, (d) generate them with AI using a well-defined schema, and (e) use dedicated question editors like Moodle Question Bank Editor or Gift2Moodle.

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:

ElementMeaning
<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
MathJax compatibility: Since your site already loads MathJax v3, LaTeX inside question text (e.g., $\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

Step 1.1

File structure

Create question bank files as static assets. Jekyll copies them to _site/ unchanged — GitHub Pages serves them like any other file.

assets/
  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
Name files with slugs matching your chapter/module structure. The lesson front matter references them by path relative to 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.
Step 1.2

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>
Step 1.3

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>
Step 1.4

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>
Step 1.5

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>
Step 1.6

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>
Step 1.7

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" not fraction="-0"
  • Test LaTeX renders: $\theta$ not &theta;

Don't

  • Don't use &amp;, &lt; 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
AI-assisted question generation: You can generate Moodle XML questions with any AI assistant. A good prompt: "Write 10 Moodle XML multichoice questions about forward kinematics of planar robots. Each question should have 1 correct answer (fraction=100) and 3 distractors (fraction=0). Include per-answer feedback and LaTeX for any equations. Output valid Moodle XML." Then review, tweak, and drop the file in assets/questions/.

Part 2 — The Quiz Engine

Step 2.1

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
Step 2.2

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;
}
Step 2.3

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;
}
Step 2.4

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>`;
}
Step 2.5

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
}
Step 2.6

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; }
}
Step 2.7

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

Step 3.1

_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 -%}
Step 3.2

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.
Multiple quizzes per lesson: If you want a mid-lesson check and an end-of-lesson quiz from different banks, create _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.
Step 3.3

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);
}
Step 3.4

Day-to-day authoring workflow

  1. Write or generate questions in Moodle XML → save to assets/questions/<topic>/<name>.xml
  2. In the lesson front matter, set quiz.bank: to the relative path
  3. Set quiz.count: 5 (or however many you want shown per attempt)
  4. Place {%- include quiz.html -%} anywhere in the lesson Markdown
  5. Run bundle exec jekyll serve — the quiz loads and shuffles on each page refresh
  6. To update questions: edit the XML file. No rebuild needed for local dev (Jekyll hot-reloads static assets). On GitHub Pages: push the commit.
CORS note for local dev: 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

Step 4.1

Exporting from a real Moodle instance

  1. In Moodle: go to Question bank → select questions → Export
  2. Choose Moodle XML format
  3. Download the .xml file
  4. Place it in assets/questions/ — no conversion needed
  5. The engine handles all Moodle-generated quirks (CDATA wrappers, extra whitespace, empty category nodes)
Step 4.2

Recommended authoring tools

ToolBest forNotes
Any text editorQuick questionsJust follow the XML structure above
Moodle GIFT format + converterFast authoringGIFT is simpler syntax; convert to XML with gift2moodle
H5P Question SetVisual editorExport as Moodle XML from H5P.org
AI (Claude/ChatGPT)Bulk generationPaste the XML schema above and ask for 10 questions
Moodle itselfTeam collaborationBuild 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
AI prompt template for question generation:
"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>.xml question bank
  • assets/js/quiz-engine.js — the complete engine from Step 2.7
  • assets/css/quiz.css — styles from Step 3.3
  • _includes/quiz.html — bridge from Step 3.1

Lesson front matter

  • Add quiz: block with bank:, count:, pass_percent:
  • Place {%- include quiz.html -%} in the lesson Markdown

Validate

  • Run xmllint --noout on 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