Jekyll eLearning Guide

A step-by-step tutorial to fix your current issues and build a full course platform — all on static Jekyll + GitHub Pages.

Jekyll 4 + GitHub Pages
Bootstrap 4 (your existing CSS)
Vanilla JS only
No backend needed

This guide covers everything in order: first fix the bugs that are silently hurting your site right now, then layer on the eLearning infrastructure. Each section gives you exact code — not pseudocode — that slots directly into your existing structure.

Phase 1 — Bug Fixes (1–2 hours)

Fix MathJax, dead CDNs, duplicate IDs, robots.txt, SVG bloat, hardcoded index

Phase 2 — Course Structure (half a day)

New _courses collection, YAML metadata, course/lesson layouts, localStorage progress

Phase 3 — Quizzes (1–2 hours)

Front matter-driven quizzes, scoring, answer reveal — zero server needed

Phase 4 — Interactive Activities (ongoing)

SVG robot arm simulator, Bode plot explorer, Python sandbox via Pyodide

Current Stack Analysis

Your site is a Jekyll 4 site using the Mundana theme (Bootstrap 4 base), with jekyll-scholar for academic references, jekyll-toc for table of contents, and Lunr.js for client-side search. Here's what's actually running:

ComponentCurrent versionStatus
JekyllGemfile (unversioned)✅ Fine
Bootstrap CSS4.2.1 compiled in main.css⚠️ Old but usable
jQuery3.3.1 from CDN⚠️ Old, fine functionally
MathJaxv2 from cdn.mathjax.org🔴 Decommissioned CDN
Bootstrap TOCv1 from cdn.rawgit.com🔴 Shut down — silently broken
FontAwesome5.3.1 (2018)⚠️ Works but outdated icons
robots.txtSaved as robot.txt🔴 Wrong filename — ignored by crawlers
SVG icons2,037 files in /resources/svgs🔴 Massive repo bloat

Phase 1 — Bug Fixes

Phase 1.1

Fix robots.txt filename

You have robot.txt (missing the s). Search engine crawlers look for robots.txt exactly — your current file is invisible to them.

Action

In your repo root, rename the file:

# In terminal / git
git mv robot.txt robots.txt
git commit -m "fix: rename robot.txt to robots.txt"

Then verify the file content. It should read:

User-agent: *
Allow: /
Sitemap: https://expred.co/sitemap.xml
The sitemap line is important — jekyll-sitemap generates /sitemap.xml automatically, and adding it to robots.txt tells Google where to find it without waiting for discovery.
Phase 1.2

Migrate MathJax v2 → v3

cdn.mathjax.org was decommissioned in 2017. Depending on your browser cache, equations may be rendering via a redirect to jsDelivr, or silently broken for some users. MathJax v3 is also 60–80% faster to load.

In _layouts/default.html, replace the entire MathJax block

_layouts/default.html — MathJax section
- <script type="text/x-mathjax-config">
-   MathJax.Hub.Config({
-     "HTML-CSS": { linebreaks: { automatic: true } },
-     SVG: { linebreaks: { automatic: true } },
-     TeX: { equationNumbers: { autoNumber: "AMS" } }
-   });
- </script>
- <script src="https://cdn.mathjax.org/mathjax/latest/MathJax.js
-   ?config=TeX-AMS-MML_HTMLorMML"></script>

+ <script>
+   window.MathJax = {
+     tex: {
+       inlineMath: [['$', '$'], ['\\(', '\\)']],
+       displayMath: [['$$', '$$'], ['\\[', '\\]']],
+       tags: 'ams'  // automatic equation numbering
+     },
+     svg: { fontCache: 'global' },
+     options: { skipHtmlTags: ['script','noscript','style','textarea','pre'] }
+   };
+ </script>
+ <script id="MathJax-script" async
+   src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js">
+ </script>
MathJax v3 uses tex-svg.js instead of the old HTML-CSS renderer. SVG output looks sharper on all screens and handles the complex robotics equations in your posts better. The async attribute means it no longer blocks page rendering.
Syntax note: The v3 config uses tex.tags: 'ams' instead of TeX.equationNumbers. Your existing LaTeX in posts (using $$...$$, \begin{equation} etc.) will continue to work unchanged.
Phase 1.3

Replace the dead rawgit CDN

You're loading Bootstrap TOC from cdn.rawgit.com, which shut down in October 2019. The request silently fails (or 404s), meaning your side TOC in post-sidebar.html may not be auto-populating correctly.

_layouts/default.html — Bootstrap TOC
- <link rel="stylesheet"
-   href="https://cdn.rawgit.com/afeld/bootstrap-toc/v1.0.1/dist/bootstrap-toc.min.css"/>
- <script
-   src="https://cdn.rawgit.com/afeld/bootstrap-toc/v1.0.1/dist/bootstrap-toc.min.js">
- </script>

+ <link rel="stylesheet"
+   href="https://cdn.jsdelivr.net/npm/[email protected]/dist/bootstrap-toc.min.css"/>
+ <script
+   src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bootstrap-toc.min.js">
+ </script>

Same library, same version — just served from a working CDN.

Phase 1.4

Fix duplicate dropdown IDs in navigation

Both dropdown items in _includes/menu-header.html share id="navbarDropdown". This violates HTML spec (IDs must be unique per page), breaks ARIA accessibility, and can cause Bootstrap's JS to attach click handlers to only the first match.

_includes/menu-header.html
<!-- Tutorials dropdown -->
<a class="nav-link dropdown-toggle" href="#"
-   id="navbarDropdown"
+   id="navbarDropdownTutorials"
   role="button" data-toggle="dropdown"
-   aria-labelledby="navbarDropdown">
+   aria-labelledby="navbarDropdownTutorials">
   Tutorials
</a>
- <div class="dropdown-menu" aria-labelledby="navbarDropdown">
+ <div class="dropdown-menu" aria-labelledby="navbarDropdownTutorials">

<!-- Categories dropdown -->
<a class="nav-link dropdown-toggle" href="#"
-   id="navbarDropdown"
+   id="navbarDropdownCategories"
   role="button" data-toggle="dropdown"
-   aria-labelledby="navbarDropdown">
+   aria-labelledby="navbarDropdownCategories">
   Categories
</a>
- <div class="dropdown-menu" aria-labelledby="navbarDropdown">
+ <div class="dropdown-menu" aria-labelledby="navbarDropdownCategories">
Phase 1.5

Fix hardcoded posts in index.html

Your index.html hardcodes site.posts[0] through site.posts[3] as four separate copy-pasted blocks. This is fragile — if a post has no image, some blocks silently render nothing. Replace with a clean loop.

Find the large "Begin post excerpts" section and replace the entire thing with:

<div class="row remove-site-content-margin">
  {%- assign featured_posts = site.posts | slice: 0, 4 -%}

  {%- assign first = featured_posts[0] -%}
  <div class="col-md-6">
    <div class="card border-0 mb-4 box-shadow">
      <a href="{{site.baseurl}}{{first.url}}">
        <div class="topfirstimage"
          style="background-image:url({{ first.image }});
                 height:200px; background-size:contain;
                 background-position:center; background-repeat:no-repeat;"></div>
      </a>
      <div class="card-body px-0 pb-0 d-flex flex-column">
        <h2 class="h2 font-weight-bold">
          <a class="text-dark" href="{{site.baseurl}}{{first.url}}">{{ first.title }}</a>
        </h2>
        <p class="excerpt">{{ first.description | strip_html | truncate: 136 }}</p>
        <small class="text-muted">{{ first.date | date: '%b %d, %Y' }}</small>
      </div>
    </div>
  </div>

  <div class="col-md-6">
    {%- for post in featured_posts offset: 1 -%}
    <div class="mb-3 d-flex align-items-center">
      {%- if post.image -%}
      <div class="col-md-4 pl-0">
        <a href="{{site.baseurl}}{{post.url}}">
          <img class="w-100" src="{{ post.image }}" alt="{{ post.title }}" loading="lazy">
        </a>
      </div>
      {%- endif -%}
      <div>
        <h2 class="mb-2 h3 font-weight-bold">
          <a class="text-dark" href="{{site.baseurl}}{{post.url}}">{{ post.title }}</a>
        </h2>
        <small class="text-muted">{{ post.date | date: '%b %d, %Y' }}</small>
      </div>
    </div>
    {%- endfor -%}
  </div>
</div>
The key improvements: uses slice + offset instead of hardcoded indices, adds loading="lazy" to thumbnails, and gracefully handles posts without images in all slots.
Phase 1.6

Remove unused SVG bloat

You have 2,037 FontAwesome SVG files (~600KB+) in resources/svgs/ shipped in your repo. These inflate clone times, GitHub storage, and GitHub Pages build times. You're already loading FontAwesome from a CDN in default.html, so these local files are never referenced.

# Check if any template actually references the local svgs folder
grep -r "resources/svgs" _layouts/ _includes/ _pages/ _posts/

# If no output (nothing uses them), safe to delete:
git rm -r resources/svgs/
git commit -m "chore: remove unused local FontAwesome SVGs (using CDN)"
Run the grep first. If your resume layout or any custom template references resources/svgs/, keep only those specific files and delete the rest.

Phase 2 — Course Structure

Phase 2.1

Add the _courses collection

Jekyll collections are the right tool here — they're like _posts but without the date-in-filename requirement, so you get clean URLs like /courses/robotics/kinematics/.

Step 1 — Add to _config.yml

# Add this block to _config.yml
collections:
  courses:
    output: true
    permalink: /courses/:path/

# Also add a default layout for course lessons
defaults:
  # ... your existing defaults ...

  # Course index pages
  - scope:
      path: "_courses"
      type: "courses"
    values:
      layout: lesson
      author: ductri

Step 2 — Create the directory structure

_courses/
  robotics/
    index.md    # Course overview page
    01-introduction.md
    02-kinematics.md
    03-dynamics.md
  control-systems/
    index.md
    01-pid-control.md
_data/courses.yml
_layouts/course.html
_layouts/lesson.html
_includes/lesson-sidebar.html
_includes/quiz.html
assets/js/quiz.js
assets/js/progress.js
Phase 2.2

Course YAML data structure

Create _data/courses.yml. This is your master course catalog — the layouts read from this to build navigation, progress tracking, and the course index page.

# _data/courses.yml
- id: robotics
  title: "Robotics Modeling, Control & Design"
  slug: robotics
  description: "From kinematics to MPC — build and control a 3R robot arm"
  level: intermediate
  duration_hours: 20
  image: "https://i.imgur.com/QlCwtKa.png"
  modules:
    - id: kinematics
      title: "Module 1: Kinematic Modeling"
      lessons:
        - slug: "01-introduction"
          title: "Introduction to Robotics"
          duration_min: 15
        - slug: "02-kinematics"
          title: "Forward & Inverse Kinematics"
          duration_min: 45
          has_quiz: true
    - id: dynamics
      title: "Module 2: Dynamics"
      lessons:
        - slug: "03-dynamics"
          title: "Lagrangian Dynamics"
          duration_min: 60
          has_quiz: true
          has_interactive: true

- id: control-systems
  title: "Control Systems Engineering"
  slug: control-systems
  description: "PID, MPC, and modern control theory"
  level: advanced
  duration_hours: 15
  image: ""
  modules:
    - id: classical
      title: "Module 1: Classical Control"
      lessons:
        - slug: "01-pid-control"
          title: "PID Control & Tuning"
          duration_min: 50
          has_interactive: true

Lesson front matter

Each file in _courses/ uses this front matter:

---
# _courses/robotics/02-kinematics.md
layout: lesson
title: "Forward & Inverse Kinematics"
course: robotics              # matches id in courses.yml
module: kinematics             # matches module id
lesson_order: 2               # for prev/next navigation
duration_min: 45
toc: true
has_quiz: true
has_interactive: false

quiz:
  - question: "What does FK stand for?"
    choices: ["Forward Kinematics", "Final Kinetics", "Force Kinetics"]
    answer: 0
    explanation: "FK = Forward Kinematics: given joint angles, find end-effector pose."
---
Phase 2.3

Course index layout

Create _layouts/course.html. This renders the course overview page (the index.md in each course folder) with a module/lesson list and overall progress bar.

<!-- _layouts/course.html -->
---
layout: default
---
{%- assign course_data = site.data.courses | where: "id", page.course_id | first -%}

<div class="container mt-4">

  <div class="row">
    <div class="col-md-8">

      <!-- Course header -->
      <p class="text-muted text-uppercase small">Course</p>
      <h1>{{ course_data.title }}</h1>
      <p class="lead">{{ course_data.description }}</p>

      <!-- Overall progress bar (filled by progress.js) -->
      <div class="mb-4">
        <div class="d-flex justify-content-between small text-muted mb-1">
          <span>Course progress</span>
          <span id="course-progress-label">0%</span>
        </div>
        <div class="progress" style="height:6px">
          <div id="course-progress-bar" class="progress-bar bg-success"
            role="progressbar" style="width:0%"></div>
        </div>
      </div>

      <!-- Module/lesson list -->
      {%- for module in course_data.modules -%}
      <div class="mb-4">
        <h4>{{ module.title }}</h4>
        <div class="list-group">
          {%- for lesson in module.lessons -%}
          {%- assign lesson_url = "/courses/" | append: page.course_id | append: "/" | append: lesson.slug | append: "/" -%}
          <a href="{{ lesson_url }}"
            class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
            data-lesson-slug="{{ page.course_id }}/{{ lesson.slug }}">
            <div>
              <span class="lesson-check mr-2 text-muted"></span>
              {{ lesson.title }}
              {%- if lesson.has_quiz -%}
              <span class="badge badge-light ml-1">Quiz</span>
              {%- endif -%}
            </div>
            <small class="text-muted">{{ lesson.duration_min }} min</small>
          </a>
          {%- endfor -%}
        </div>
      </div>
      {%- endfor -%}

    </div>
  </div>
</div>

<script>
// Inject progress from localStorage into the course overview
document.addEventListener('DOMContentLoaded', function() {
  const courseId = '{{ page.course_id }}';
  const lessonLinks = document.querySelectorAll('[data-lesson-slug]');
  let completed = 0;
  lessonLinks.forEach(function(link) {
    const slug = link.dataset.lessonSlug;
    if (localStorage.getItem('lesson_done_' + slug) === '1') {
      link.querySelector('.lesson-check').textContent = '✓';
      link.querySelector('.lesson-check').style.color = '#28a745';
      completed++;
    }
  });
  const pct = lessonLinks.length ? Math.round(completed / lessonLinks.length * 100) : 0;
  document.getElementById('course-progress-bar').style.width = pct + '%';
  document.getElementById('course-progress-label').textContent = pct + '%';
});
</script>
Phase 2.4

Lesson layout with sidebar

Create _layouts/lesson.html. This is similar to your existing post-sidebar.html but adds lesson-specific elements: module breadcrumb, estimated reading time, prev/next lesson navigation (not chronological, but by lesson_order), and a "Mark complete" button.

<!-- _layouts/lesson.html -->
---
layout: default
---
{%- assign course_data = site.data.courses | where: "id", page.course | first -%}
{%- assign all_lessons = site.courses | where: "course", page.course | sort: "lesson_order" -%}
{%- assign lesson_index = 0 -%}
{%- for l in all_lessons -%}
  {%- if l.url == page.url -%}{%- assign lesson_index = forloop.index0 -%}{%- endif -%}
{%- endfor -%}
{%- assign prev_lesson = all_lessons[lesson_index | minus: 1] -%}
{%- assign next_lesson = all_lessons[lesson_index | plus: 1] -%}

<div class="container mt-4">

  <!-- Breadcrumb -->
  <nav aria-label="breadcrumb">
    <ol class="breadcrumb bg-white pl-0 small">
      <li class="breadcrumb-item"><a href="/courses/">Courses</a></li>
      <li class="breadcrumb-item"><a href="/courses/{{ page.course }}/">{{ course_data.title }}</a></li>
      <li class="breadcrumb-item active">{{ page.title }}</li>
    </ol>
  </nav>

  <div class="row">

    <!-- Lesson sidebar: module nav -->
    <div class="col-md-3 d-none d-md-block">
      <div class="sticky-top" style="top:80px">
        {%- include lesson-sidebar.html course=course_data lessons=all_lessons -%}
      </div>
    </div>

    <!-- Main lesson content -->
    <div class="col-md-9">
      <h1>{{ page.title }}</h1>
      <p class="text-muted small">
        ⏱ {{ page.duration_min }} min read
        {%- if page.has_quiz -%} · 📝 Includes quiz{%- endif -%}
        {%- if page.has_interactive -%} · 🔬 Interactive demo{%- endif -%}
      </p>

      <article class="article-post">{{ content }}</article>

      <!-- Quiz (rendered if front matter has quiz: data) -->
      {%- if page.quiz -%}
      {%- include quiz.html quiz=page.quiz -%}
      {%- endif -%}

      <!-- Mark complete button -->
      <div class="my-5 text-center">
        <button id="btn-complete" class="btn btn-success btn-lg"
          data-course="{{ page.course }}"
          data-slug="{{ page.course }}/{{ page.slug }}">
          Mark as Complete ✓
        </button>
      </div>

      <!-- Prev / Next navigation -->
      <div class="d-flex justify-content-between mt-4 pt-3 border-top">
        {%- if prev_lesson -%}
        <a href="{{ prev_lesson.url }}" class="btn btn-outline-secondary">
          ← {{ prev_lesson.title }}
        </a>
        {%- else -%}<div></div>{%- endif -%}
        {%- if next_lesson -%}
        <a href="{{ next_lesson.url }}" class="btn btn-success">
          {{ next_lesson.title }} →
        </a>
        {%- else -%}<div></div>{%- endif -%}
      </div>

    </div>
  </div>
</div>
Phase 2.5

Progress tracking with localStorage

Create assets/js/progress.js. This handles the "Mark complete" button and persists state in localStorage — no login, no server, works offline.

// assets/js/progress.js
(function() {
  const btn = document.getElementById('btn-complete');
  if (!btn) return;

  const slug = btn.dataset.slug;
  const KEY = 'lesson_done_' + slug;

  function setDone() {
    btn.textContent = '✓ Completed';
    btn.classList.remove('btn-success');
    btn.classList.add('btn-outline-success');
    btn.disabled = true;
  }

  // Restore state on load
  if (localStorage.getItem(KEY) === '1') {
    setDone();
  }

  btn.addEventListener('click', function() {
    localStorage.setItem(KEY, '1');
    setDone();
    // Optional: auto-advance to next lesson after 1.2s
    const nextBtn = document.querySelector('a.btn-success[href]');
    if (nextBtn) {
      setTimeout(function() {
        nextBtn.style.animation = 'pulse 0.4s ease';
      }, 800);
    }
  });
})();

Add this script to the bottom of _layouts/lesson.html (before </body> via the default layout, or via a scripts front matter variable if your default layout supports it):

<script src="{{ site.baseurl }}/assets/js/progress.js"></script>
Phase 2.6

Lesson sidebar include

Create _includes/lesson-sidebar.html. This renders the module navigation tree with completion checkmarks.

<!-- _includes/lesson-sidebar.html -->
<div class="lesson-sidebar">
  <h6 class="text-uppercase text-muted small font-weight-bold mb-3">
    <a href="/courses/{{ include.course.id }}/" class="text-muted">
      ← {{ include.course.title }}
    </a>
  </h6>

  {%- for module in include.course.modules -%}
  <div class="mb-3">
    <small class="text-uppercase text-muted font-weight-bold d-block mb-1">
      {{ module.title }}
    </small>
    {%- for lesson in module.lessons -%}
    {%- assign lesson_url = "/courses/" | append: include.course.id | append: "/" | append: lesson.slug | append: "/" -%}
    {%- assign is_active = false -%}
    {%- if page.url == lesson_url -%}{%- assign is_active = true -%}{%- endif -%}
    <a href="{{ lesson_url }}"
      class="d-block py-1 small {% if is_active %}font-weight-bold text-dark{% else %}text-muted{% endif %}"
      data-sidebar-lesson="{{ include.course.id }}/{{ lesson.slug }}">
      <span class="sidebar-check"></span>
      {{ lesson.title }}
    </a>
    {%- endfor -%}
  </div>
  {%- endfor -%}
</div>

<script>
document.querySelectorAll('[data-sidebar-lesson]').forEach(function(el) {
  const key = 'lesson_done_' + el.dataset.sidebarLesson;
  if (localStorage.getItem(key) === '1') {
    el.querySelector('.sidebar-check').textContent = '✓ ';
    el.querySelector('.sidebar-check').style.color = '#28a745';
  }
});
</script>

Finally, add a Courses page to _pages/courses.html and link it in your nav:

---
title: Courses
layout: page
permalink: "/courses/"
---

<h1>Courses</h1>
<div class="row">
{%- for course in site.data.courses -%}
<div class="col-md-6 mb-4">
  <div class="card h-100">
    {%- if course.image -%}
    <img class="card-img-top" src="{{ course.image }}" alt="{{ course.title }}" style="height:160px;object-fit:cover">
    {%- endif -%}
    <div class="card-body">
      <h5 class="card-title">{{ course.title }}</h5>
      <p class="card-text text-muted small">{{ course.description }}</p>
      <a href="/courses/{{ course.id }}/" class="btn btn-dark btn-sm">Start Course</a>
    </div>
  </div>
</div>
{%- endfor -%}
</div>

Phase 3 — Quizzes

Phase 3.1

Quiz data in front matter

Your quizzes live directly in the lesson's front matter YAML — no separate file, no database. This keeps content and assessment co-located and version-controlled together.

---
title: "Forward Kinematics"
course: robotics
lesson_order: 2

quiz:
  - question: "Given θ₁=90° and θ₂=0°, which direction does the 2R arm point?"
    choices:
      - "Along the positive X axis"
      - "Along the positive Y axis"
      - "At 45 degrees"
      - "Along the negative X axis"
    answer: 1
    explanation: "With θ₁=90°, the first link rotates to point along +Y.
      The second link (θ₂=0°) is parallel to the first, so the end-effector
      is along +Y."

  - question: "Inverse kinematics for a 2R planar arm can have how many solutions?"
    choices:
      - "Always exactly 1"
      - "0, 1, or 2"
      - "Always exactly 2"
      - "Infinite"
    answer: 1
    explanation: "A 2R arm has up to 2 IK solutions (elbow-up and elbow-down).
      If the target is out of reach, there are 0 solutions. If exactly at
      max reach, there is 1."
---
Phase 3.2

quiz.html include

Create _includes/quiz.html. It renders the quiz UI from front matter data at build time — the HTML is static, only the scoring logic is JavaScript.

<!-- _includes/quiz.html -->
{%- assign quiz_items = include.quiz -%}
<div class="quiz-container my-5" id="quiz-{{ page.slug | default: page.title | slugify }}">
  <div class="card border-0" style="background:#f8f9fa">
    <div class="card-body p-4">
      <h5 class="font-weight-bold mb-4">📝 Knowledge Check</h5>

      {%- for item in quiz_items -%}
      <div class="quiz-question mb-4"
        data-answer="{{ item.answer }}"
        data-explanation="{{ item.explanation | escape }}">

        <p class="font-weight-bold">{{ forloop.index }}. {{ item.question }}</p>

        <div class="quiz-choices">
          {%- for choice in item.choices -%}
          <div class="quiz-choice" data-index="{{ forloop.index0 }}">
            <button type="button"
              class="btn btn-outline-secondary btn-sm w-100 text-left mb-2"
              style="white-space:normal">
              <span class="choice-letter">{{ forloop.index0 | plus: 65 | chr }}</span>. {{ choice }}
            </button>
          </div>
          {%- endfor -%}
        </div>

        <!-- Feedback (hidden until answered) -->
        <div class="quiz-feedback mt-2" style="display:none"></div>

      </div>
      {%- endfor -%}

      <!-- Score display -->
      <div id="quiz-score" class="alert alert-info mt-3" style="display:none"></div>
      <button id="quiz-retry" class="btn btn-outline-dark mt-2" style="display:none">Try Again</button>
    </div>
  </div>
</div>
About the letter conversion: The Liquid filter | plus: 65 | chr converts 0 → 'A', 1 → 'B', etc. This is built into Jekyll's Liquid — no plugin needed.
Phase 3.3

Quiz JavaScript

Create assets/js/quiz.js. Add it at the end of _layouts/lesson.html (or conditionally load it only on pages where page.has_quiz == true).

// assets/js/quiz.js
(function() {
  document.querySelectorAll('.quiz-container').forEach(function(container) {
    const questions = container.querySelectorAll('.quiz-question');
    let answered = 0, score = 0;

    questions.forEach(function(q) {
      const correct = parseInt(q.dataset.answer, 10);
      const explanation = q.dataset.explanation;
      const feedback = q.querySelector('.quiz-feedback');

      q.querySelectorAll('.quiz-choice button').forEach(function(btn) {
        btn.addEventListener('click', function() {
          // Prevent re-answering
          if (q.classList.contains('answered')) return;
          q.classList.add('answered');

          const chosen = parseInt(btn.closest('.quiz-choice').dataset.index, 10);
          const isCorrect = chosen === correct;

          // Style all choices: green correct, red wrong chosen
          q.querySelectorAll('.quiz-choice button').forEach(function(b, i) {
            b.disabled = true;
            if (i === correct) {
              b.classList.replace('btn-outline-secondary', 'btn-success');
            } else if (i === chosen && !isCorrect) {
              b.classList.replace('btn-outline-secondary', 'btn-danger');
            }
          });

          // Show explanation
          if (explanation) {
            feedback.style.display = 'block';
            feedback.className = 'quiz-feedback mt-2 p-2 rounded small ' +
              (isCorrect ? 'bg-success text-white' : 'bg-warning');
            feedback.innerHTML = (isCorrect ? '✓ Correct! ' : '✗ Not quite. ') + explanation;
          }

          if (isCorrect) score++;
          answered++;

          // Show final score once all questions answered
          if (answered === questions.length) {
            const scoreEl = container.querySelector('#quiz-score');
            const pct = Math.round(score / questions.length * 100);
            scoreEl.style.display = 'block';
            scoreEl.className = 'alert mt-3 ' + (pct >= 70 ? 'alert-success' : 'alert-warning');
            scoreEl.textContent = `Score: ${score}/${questions.length} (${pct}%)` +
              (pct >= 70 ? ' — Great job!' : ' — Keep reviewing!');
            container.querySelector('#quiz-retry').style.display = 'inline-block';
          }
        });
      });
    });

    // Retry button resets everything
    container.querySelector('#quiz-retry')?.addEventListener('click', function() {
      answered = 0; score = 0;
      questions.forEach(function(q) {
        q.classList.remove('answered');
        q.querySelector('.quiz-feedback').style.display = 'none';
        q.querySelectorAll('button').forEach(function(b) {
          b.disabled = false;
          b.className = b.className
            .replace('btn-success', 'btn-outline-secondary')
            .replace('btn-danger', 'btn-outline-secondary');
        });
      });
      container.querySelector('#quiz-score').style.display = 'none';
      this.style.display = 'none';
    });
  });
})();
Phase 3.4

Loading quiz.js conditionally

You don't want quiz.js loaded on every page. In _layouts/lesson.html, add the script conditionally right before </body>:

{%- if page.has_quiz -%}
<script src="{{ site.baseurl }}/assets/js/quiz.js"></script>
{%- endif -%}
<script src="{{ site.baseurl }}/assets/js/progress.js"></script>
You can also embed a quiz directly in any existing _posts/ blog post by adding the quiz: block to its front matter and calling {%- include quiz.html quiz=page.quiz -%} anywhere in the post body — the quiz isn't limited to course lessons.

Phase 4 — Interactive Activities

Phase 4.1

SVG Robot Arm Simulator

This is a self-contained include you can drop into any lesson with {%- include robot-arm.html joints=2 -%}. It renders an interactive 2R or 3R planar robot arm where learners can drag sliders to change joint angles and see the end-effector position update in real time — directly reinforcing your FK lessons.

Create _includes/robot-arm.html:

<!-- _includes/robot-arm.html
     Usage: {%- include robot-arm.html joints=2 l1=150 l2=120 -%} -->

{%- assign n_joints = include.joints | default: 2 -%}
{%- assign l1 = include.l1 | default: 150 -%}
{%- assign l2 = include.l2 | default: 120 -%}
{%- assign l3 = include.l3 | default: 100 -%}

<div class="interactive-widget my-4 p-3 border rounded">
  <h6 class="text-muted text-uppercase small">🔬 Interactive: Robot Arm Simulator</h6>

  <div class="d-flex flex-wrap align-items-start">
    <!-- SVG canvas -->
    <svg id="robot-svg" width="320" height="320"
      style="border:1px solid #dee2e6; border-radius:4px; background:#f8f9fa">
      <!-- Grid lines -->
      <line x1="160" y1="0" x2="160" y2="320" stroke="#dee2e6" stroke-dasharray="4"/>
      <line x1="0" y1="160" x2="320" y2="160" stroke="#dee2e6" stroke-dasharray="4"/>
      <!-- Links (filled by JS) -->
      <line id="link1" stroke="#343a40" stroke-width="6" stroke-linecap="round"/>
      <line id="link2" stroke="#495057" stroke-width="5" stroke-linecap="round"/>
      {%- if n_joints == 3 -%}
      <line id="link3" stroke="#6c757d" stroke-width="4" stroke-linecap="round"/>
      {%- endif -%}
      <!-- Joints -->
      <circle id="j0" cx="160" cy="160" r="7" fill="#212529"/>
      <circle id="j1" r="6" fill="#495057"/>
      <circle id="j2" r="5" fill="#6c757d"/>
      {%- if n_joints == 3 -%}
      <circle id="j3" r="5" fill="#03a87c"/>
      {%- endif -%}
      <!-- End effector marker -->
      <circle id="ee" r="8" fill="none" stroke="#03a87c" stroke-width="2"/>
      <!-- Coordinates label -->
      <text id="ee-label" font-size="11" fill="#03a87c"/>
    </svg>

    <!-- Sliders -->
    <div class="ml-3 mt-2" style="min-width:200px">
      <div class="mb-3">
        <label>θ₁ = <span id="val-t1"></span></label>
        <input type="range" id="slider-t1" min="-180" max="180" value="0" class="form-control-range">
      </div>
      <div class="mb-3">
        <label>θ₂ = <span id="val-t2"></span></label>
        <input type="range" id="slider-t2" min="-180" max="180" value="0" class="form-control-range">
      </div>
      {%- if n_joints == 3 -%}
      <div class="mb-3">
        <label>θ₃ = <span id="val-t3"></span></label>
        <input type="range" id="slider-t3" min="-180" max="180" value="0" class="form-control-range">
      </div>
      {%- endif -%}
      <small class="text-muted">
        End-effector:
x = <span id="ee-x" class="font-weight-bold">0.00</span>
y = <span id="ee-y" class="font-weight-bold">0.00</span> </small> </div> </div> </div> <script> (function() { const L = [0, {{ l1 }}, {{ l2 }}{%- if n_joints == 3 -%}, {{ l3 }}{%- endif -%}]; const N = {{ n_joints }}; const SCALE = 160 / (L[1] + L[2] + (N > 2 ? L[3] : 0)); // fit in SVG const CX = 160, CY = 160; // base at SVG center function deg2rad(d) { return d * Math.PI / 180; } function update() { const thetas = [ deg2rad(parseFloat(document.getElementById('slider-t1').value)), deg2rad(parseFloat(document.getElementById('slider-t2').value)), N > 2 ? deg2rad(parseFloat(document.getElementById('slider-t3').value)) : 0 ]; // Forward kinematics: cumulative angle let angle = 0; let px = CX, py = CY; const points = [[px, py]]; for (let i = 0; i < N; i++) { angle += thetas[i]; px += L[i+1] * SCALE * Math.cos(angle); py -= L[i+1] * SCALE * Math.sin(angle); // SVG y-axis is flipped points.push([px, py]); } // Update links for (let i = 1; i <= N; i++) { const link = document.getElementById('link' + i); if (link) { link.setAttribute('x1', points[i-1][0]); link.setAttribute('y1', points[i-1][1]); link.setAttribute('x2', points[i][0]); link.setAttribute('y2', points[i][1]); } } // Update joint circles for (let i = 0; i <= N; i++) { const j = document.getElementById('j' + i); if (j) { j.setAttribute('cx', points[i][0]); j.setAttribute('cy', points[i][1]); } } // End-effector const [ex, ey] = points[N]; const ee = document.getElementById('ee'); ee.setAttribute('cx', ex); ee.setAttribute('cy', ey); const lbl = document.getElementById('ee-label'); lbl.setAttribute('x', ex + 10); lbl.setAttribute('y', ey - 4); // Real-world coords (normalized by SCALE, origin at base) const rx = ((ex - CX) / SCALE).toFixed(1); const ry = ((CY - ey) / SCALE).toFixed(1); document.getElementById('ee-x').textContent = rx; document.getElementById('ee-y').textContent = ry; // Update slider labels document.getElementById('val-t1').textContent = document.getElementById('slider-t1').value + '°'; document.getElementById('val-t2').textContent = document.getElementById('slider-t2').value + '°'; if (N > 2) document.getElementById('val-t3').textContent = document.getElementById('slider-t3').value + '°'; } ['slider-t1', 'slider-t2', 'slider-t3'].forEach(function(id) { const el = document.getElementById(id); if (el) el.addEventListener('input', update); }); update(); // initial render })(); </script>

Usage in your kinematics lesson

{%- include robot-arm.html joints=2 l1=150 l2=120 -%}

{%- include robot-arm.html joints=3 l1=130 l2=100 l3=80 -%}
Phase 4.2

Bode Plot Explorer (Plotly.js)

Plotly.js is heavy (~3MB), so load it only on pages that explicitly request it. Create _includes/bode-plot.html:

<!-- _includes/bode-plot.html
     Usage: {%- include bode-plot.html Kp=1.0 Ti=1.0 Td=0.1 -%} -->

<div class="interactive-widget my-4 p-3 border rounded">
  <h6 class="text-muted text-uppercase small">🔬 Interactive: Bode Plot Explorer</h6>
  <div class="row">
    <div class="col-md-3">
      <label>Kp = <span id="bode-kp-val">{{ include.Kp | default: 1.0 }}</span></label>
      <input id="bode-kp" type="range" min="0.1" max="10" step="0.1"
        value="{{ include.Kp | default: 1.0 }}" class="form-control-range">
      <label>Ti = <span id="bode-ti-val">{{ include.Ti | default: 1.0 }}</span></label>
      <input id="bode-ti" type="range" min="0.1" max="5" step="0.1"
        value="{{ include.Ti | default: 1.0 }}" class="form-control-range">
      <label>Td = <span id="bode-td-val">{{ include.Td | default: 0.1 }}</span></label>
      <input id="bode-td" type="range" min="0.01" max="2" step="0.01"
        value="{{ include.Td | default: 0.1 }}" class="form-control-range">
    </div>
    <div class="col-md-9">
      <div id="bode-plot" style="height:300px"></div>
    </div>
  </div>
</div>

<!-- Load Plotly only once even if include is called multiple times -->
<script>
if (!window._plotlyLoaded) {
  window._plotlyLoaded = true;
  const s = document.createElement('script');
  s.src = 'https://cdn.plot.ly/plotly-basic-2.27.0.min.js'; // basic = smaller
  s.onload = initBode;
  document.head.appendChild(s);
} else { initBode(); }

function initBode() {
  function pidBode(Kp, Ti, Td) {
    const freqs = Array.from({length: 200}, (_, i) => Math.pow(10, -2 + i*4/199));
    const mag = [], phase = [];
    freqs.forEach(function(w) {
      // PID: C(jw) = Kp(1 + 1/(Ti*jw) + Td*jw)
      const re = Kp * (1 - Td*w*Td*w/(Ti)); // simplified
      const im = Kp * (Td*w - 1/(Ti*w));
      mag.push(20*Math.log10(Math.sqrt(re*re+im*im)));
      phase.push(Math.atan2(im,re)*180/Math.PI);
    });
    return { freqs, mag, phase };
  }

  function drawBode() {
    const Kp = +document.getElementById('bode-kp').value;
    const Ti = +document.getElementById('bode-ti').value;
    const Td = +document.getElementById('bode-td').value;
    document.getElementById('bode-kp-val').textContent = Kp.toFixed(1);
    document.getElementById('bode-ti-val').textContent = Ti.toFixed(1);
    document.getElementById('bode-td-val').textContent = Td.toFixed(2);
    const { freqs, mag, phase } = pidBode(Kp, Ti, Td);
    Plotly.react('bode-plot', [
      { x: freqs, y: mag, name: 'Magnitude (dB)', type: 'scatter', line: {color:'#03a87c'} },
      { x: freqs, y: phase, name: 'Phase (°)', yaxis: 'y2', type: 'scatter', line: {color:'#dc3545', dash:'dot'} }
    ], {
      xaxis: { type: 'log', title: 'Frequency (rad/s)' },
      yaxis: { title: 'Magnitude (dB)' },
      yaxis2: { title: 'Phase (°)', overlaying: 'y', side: 'right' },
      margin: { t: 20, r: 60 }, legend: { orientation: 'h' },
      paper_bgcolor: '#f8f9fa', plot_bgcolor: '#f8f9fa'
    }, { responsive: true });
  }

  ['bode-kp','bode-ti','bode-td'].forEach(id => document.getElementById(id)?.addEventListener('input', drawBode));
  drawBode();
}
</script>
Phase 4.3

Python code sandbox (Pyodide)

Pyodide runs Python entirely in the browser via WebAssembly. This lets learners run and modify your control simulation code directly in the lesson — no server, no install. Fair warning: the Pyodide runtime is ~10MB, so load it lazily only on lessons that need it.

<!-- _includes/python-sandbox.html
     Usage: {%- include python-sandbox.html -%} -->

<div class="interactive-widget my-4 p-3 border rounded">
  <h6 class="text-muted text-uppercase small">🐍 Python Sandbox</h6>
  <textarea id="pyodide-code" class="form-control font-monospace"
    rows="8" style="font-family:monospace;font-size:13px">import numpy as np

# 2R planar robot forward kinematics
L = [1.0, 0.8]      # link lengths
theta = [45, -30]   # joint angles in degrees

theta_r = np.radians(theta)
x = L[0]*np.cos(theta_r[0]) + L[1]*np.cos(theta_r[0]+theta_r[1])
y = L[0]*np.sin(theta_r[0]) + L[1]*np.sin(theta_r[0]+theta_r[1])
print(f"End-effector: x={x:.3f}, y={y:.3f}")</textarea>
  <div class="mt-2 d-flex gap-2 align-items-center">
    <button id="pyodide-run" class="btn btn-dark btn-sm" disabled>Loading Python...</button>
    <small class="text-muted" id="pyodide-status">NumPy available.</small>
  </div>
  <pre id="pyodide-output" class="mt-2 p-2 bg-dark text-light rounded small"
    style="min-height:40px;font-family:monospace"></pre>
</div>

<script>
if (!window._pyodideLoading) {
  window._pyodideLoading = true;
  const s = document.createElement('script');
  s.src = 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js';
  document.head.appendChild(s);
  s.onload = async function() {
    window._pyodide = await loadPyodide();
    await window._pyodide.loadPackage('numpy');
    document.querySelectorAll('[id="pyodide-run"]').forEach(function(btn) {
      btn.disabled = false;
      btn.textContent = '▶ Run';
    });
  };
}

document.getElementById('pyodide-run')?.addEventListener('click', async function() {
  const code = document.getElementById('pyodide-code').value;
  const out = document.getElementById('pyodide-output');
  out.textContent = 'Running...';
  try {
    let stdout = '';
    window._pyodide.setStdout({ batched: (s) => { stdout += s + '\n'; } });
    await window._pyodide.runPythonAsync(code);
    out.textContent = stdout || '(no output)';
    out.style.color = '#c3e88d';
  } catch (e) {
    out.textContent = 'Error: ' + e.message;
    out.style.color = '#ff8888';
  }
});
</script>
Phase 4.4

Clean usage pattern in lesson files

Here's how a complete lesson markdown file looks with all the interactive components:

---
layout: lesson
title: "Forward & Inverse Kinematics"
course: robotics
lesson_order: 2
duration_min: 45
has_quiz: true
has_interactive: true
toc: true

quiz:
  - question: "What does FK stand for?"
    choices: ["Forward Kinematics", "Final Kinetics", "Force Kinetics"]
    answer: 0
    explanation: "FK = Forward Kinematics."
---

## Introduction

Kinematics is the study of motion without regard to forces...

## Forward Kinematics

Given joint angles $$\theta_1, \theta_2$$, find end-effector position...

**Try it live:**
{%- include robot-arm.html joints=2 l1=150 l2=120 -%}

## Coding FK

Run the FK calculation yourself:
{%- include python-sandbox.html -%}

## Control System Bode Plot

{%- include bode-plot.html Kp=2.0 Ti=0.5 Td=0.05 -%}

Phase 5 — Polish

Phase 5.1

SEO & Performance tweaks

Small additions to _layouts/default.html that have real impact:

<!-- Add to <head> in default.html -->

<!-- Preconnect to CDN origins you use -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.jsdelivr.net">

<!-- Google Fonts: add display=swap to prevent FOIT -->
<!-- Change your existing font links from: -->
- <link href="https://fonts.googleapis.com/css?family=Lora:400,400i,700">
<!-- To: -->
+ <link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,700;1,400&display=swap">

<!-- Open Graph for social sharing -->
<meta property="og:title" content="{{ page.title }} | {{ site.name }}">
<meta property="og:description" content="{{ page.description | default: site.description }}">
<meta property="og:image" content="{{ page.image | default: site.logo | absolute_url }}">
<meta property="og:url" content="{{ page.url | absolute_url }}">
<meta name="twitter:card" content="summary_large_image">

Also add loading="lazy" to every <img> in your layouts that isn't above the fold — this is the single highest-impact performance change you can make.

Phase 5.2

Pin your Gemfile versions

Your current Gemfile has no version pins. This means bundle install on a new machine might pick up a breaking gem version. Update it:

source "https://rubygems.org"

gem 'jekyll', '~> 4.3'
gem 'wdm', '>= 0.1.0', platforms: [:mingw, :x64_mingw, :mswin]
gem 'webrick', '~> 1.8'   # required for Ruby 3.x

group :jekyll_plugins do
  gem 'jekyll-feed',     '~> 0.17'
  gem 'jekyll-sitemap',  '~> 1.4'
  gem 'jekyll-paginate', '~> 1.1'
  gem 'jekyll-seo-tag',  '~> 2.8'
  gem 'jekyll-scholar',  '~> 7.1'
  gem 'jekyll-toc',      '~> 0.19'
end

After editing, run bundle update to regenerate Gemfile.lock (rename Gemfile_.lockGemfile.lock first).

Phase 5.3

GitHub Actions deploy (future-proof)

GitHub Pages natively only supports a limited set of plugins. Since you're using jekyll-scholar (not on the whitelist), you need to build with GitHub Actions. Create .github/workflows/deploy.yml:

name: Deploy Jekyll site

on:
  push:
    branches: ["main"]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true   # caches gems between runs
      - name: Build site
        run: bundle exec jekyll build
        env:
          JEKYLL_ENV: production
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./_site

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4
In your GitHub repo settings → Pages → Source, change from "Deploy from branch" to "GitHub Actions" after adding this file. This unlocks all Jekyll plugins, not just the GitHub Pages whitelist.

Summary checklist

Phase 1 — Fixes

  • Rename robot.txtrobots.txt and add sitemap line
  • Replace MathJax v2 CDN with v3 from jsdelivr
  • Replace rawgit Bootstrap TOC with jsdelivr
  • Fix duplicate id="navbarDropdown" in menu-header.html
  • Replace hardcoded posts[0]–[3] in index.html with a loop
  • Delete unused SVGs from resources/svgs/
  • Rename Gemfile_.lockGemfile.lock

Phase 2 — Courses

  • Add _courses collection to _config.yml
  • Create _data/courses.yml catalog
  • Create _layouts/course.html overview layout
  • Create _layouts/lesson.html lesson layout
  • Create _includes/lesson-sidebar.html
  • Create assets/js/progress.js for localStorage tracking
  • Create _pages/courses.html and add to nav

Phase 3 — Quizzes

  • Create _includes/quiz.html
  • Create assets/js/quiz.js
  • Add quiz: blocks to lesson front matter

Phase 4 — Interactives

  • Create _includes/robot-arm.html (SVG + vanilla JS)
  • Create _includes/bode-plot.html (lazy-loaded Plotly)
  • Create _includes/python-sandbox.html (Pyodide)