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.
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.
Fix MathJax, dead CDNs, duplicate IDs, robots.txt, SVG bloat, hardcoded index
New _courses collection, YAML metadata, course/lesson layouts, localStorage progress
Front matter-driven quizzes, scoring, answer reveal — zero server needed
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:
| Component | Current version | Status |
|---|---|---|
| Jekyll | Gemfile (unversioned) | ✅ Fine |
| Bootstrap CSS | 4.2.1 compiled in main.css | ⚠️ Old but usable |
| jQuery | 3.3.1 from CDN | ⚠️ Old, fine functionally |
| MathJax | v2 from cdn.mathjax.org | 🔴 Decommissioned CDN |
| Bootstrap TOC | v1 from cdn.rawgit.com | 🔴 Shut down — silently broken |
| FontAwesome | 5.3.1 (2018) | ⚠️ Works but outdated icons |
| robots.txt | Saved as robot.txt | 🔴 Wrong filename — ignored by crawlers |
| SVG icons | 2,037 files in /resources/svgs | 🔴 Massive repo bloat |
Phase 1 — Bug Fixes
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
/sitemap.xml automatically, and adding it to robots.txt tells Google where to find it without waiting for discovery.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
- <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>
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.tex.tags: 'ams' instead of TeX.equationNumbers. Your existing LaTeX in posts (using $$...$$, \begin{equation} etc.) will continue to work unchanged.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.
- <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.
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>
slice + offset instead of hardcoded indices, adds loading="lazy" to thumbnails, and gracefully handles posts without images in all slots.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)"
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
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
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
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."
---
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>
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>
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 3 — Quizzes
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."
---
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>
| plus: 65 | chr converts 0 → 'A', 1 → 'B', etc. This is built into Jekyll's Liquid — no plugin needed.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';
});
});
})();
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>
_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
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">0°</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">0°</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">0°</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 -%}
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>
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>
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
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.
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_.lock → Gemfile.lock first).
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
Summary checklist
Phase 1 — Fixes
- Rename
robot.txt→robots.txtand 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_.lock→Gemfile.lock
Phase 2 — Courses
- Add
_coursescollection to_config.yml - Create
_data/courses.ymlcatalog - Create
_layouts/course.htmloverview layout - Create
_layouts/lesson.htmllesson layout - Create
_includes/lesson-sidebar.html - Create
assets/js/progress.jsfor localStorage tracking - Create
_pages/courses.htmland 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)