Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ mlruns/
.link-cache.json
.claude/
catalog.json
site/course/
site/materials.js

# Generated by site/build.js on every Vercel deploy (buildCommand) — never
# commit, so they can't drift or drop URLs. Regenerated fresh from PHASES.
Expand Down
20 changes: 20 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Run the course website locally

The website is static and has no package dependencies. From the repository root:

```sh
node site/build.js
python -m http.server 4173 --directory site
```

Open <http://localhost:4173>. Use `python3` instead of `python` on systems where that is the Python command.

The build generates `site/data.js`, `site/materials.js`, and `site/course/`. The last two are ignored by Git because they are rebuilt from the course files already in this repository. Serve the site over HTTP instead of opening `index.html` directly so lesson and quiz files can be loaded by the browser.

Progress, reading position, theme, and reading preferences are stored only in the current browser with `localStorage`. No account or backend is required.

Run the progress tracker check with:

```sh
node site/tests/progress.test.js
```
9 changes: 7 additions & 2 deletions site/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About - AI Engineering from Scratch</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' fill='%23fafaf5'/><rect x='2' y='2' width='28' height='28' fill='none' stroke='%233553ff' stroke-width='1.2'/><text x='6' y='22' font-size='14' font-family='monospace' fill='%233553ff'>AI</text></svg>">
<meta name="description" content="What AI Engineering from Scratch is, why it exists, who builds it, and how the site is made. A free, open-source, MIT-licensed curriculum.">
<meta name="description" content="About AI Engineering from Scratch, the open-source curriculum created by Rohit Ghumare and contributors.">
<link rel="canonical" href="https://aiengineeringfromscratch.com/about.html">
<meta property="og:title" content="About · AI Engineering from Scratch">
<meta property="og:description" content="Why this curriculum exists, who builds it, and how the site is made. Free, open source, MIT.">
Expand Down Expand Up @@ -109,6 +109,11 @@
<div class="about-eyebrow">About</div>
<h1>About this project</h1>

<aside class="source-credit" aria-label="Original course attribution">
<strong>Built in the open.</strong>
<span>The <a href="https://github.com/rohitg00/ai-engineering-from-scratch" target="_blank" rel="noopener">AI Engineering from Scratch repository</a> and its course content are created and maintained by <a href="https://github.com/rohitg00" target="_blank" rel="noopener">Rohit Ghumare</a> and contributors. The website is part of the same open-source project.</span>
</aside>

<p class="lede">AI Engineering from Scratch is a free, open-source curriculum that builds every core AI algorithm by hand. 503 lessons across 20 phases, from linear algebra to autonomous agents, in Python, TypeScript, Rust, and Julia.</p>

<h2>Why it exists</h2>
Expand All @@ -120,7 +125,7 @@ <h2>How it is made</h2>
<p>The site itself is deliberately plain: hand-written HTML, CSS, and vanilla JavaScript, no framework. A single build script (<code>site/build.js</code>) reads the lesson Markdown in the repository and generates the catalog, search index, sitemap, and <code>llms.txt</code> on every deploy, so the published numbers can never drift from the source. It is hosted on Vercel.</p>

<h2>Who builds it</h2>
<p>Maintained by <a href="https://github.com/rohitg00" target="_blank" rel="noopener">Rohit Ghumare</a> and contributors. It is MIT-licensed and free forever. There is no token, no course upsell, and no gated content.</p>
<p>The curriculum and website are maintained by <a href="https://github.com/rohitg00" target="_blank" rel="noopener">Rohit Ghumare</a> and contributors. The project is MIT-licensed and free forever.</p>

<h2>Get involved</h2>
<ul>
Expand Down
186 changes: 181 additions & 5 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
document.addEventListener('DOMContentLoaded', function () {
initThemeToggle();
populateStats();
renderLearningCommand();
renderPhases();
initCourseUniverse();
initStaggerIndex();
initModal();
initCopyButton();
Expand Down Expand Up @@ -50,18 +52,21 @@
var lessons = PHASES[i].lessons;
totalLessons += lessons.length;
for (var j = 0; j < lessons.length; j++) {
var staticDone = lessons[j].status === 'complete';
var userDone = false;
if (hasProgress && lessons[j].url) {
var lp = window.AIFSProgress.extractPath(lessons[j].url);
if (lp) userDone = window.AIFSProgress.isLessonComplete(lp);
}
if (staticDone || userDone) completeLessons++;
if (userDone) completeLessons++;
}
}
var completePhases = 0;
for (var p = 0; p < PHASES.length; p++) {
if (PHASES[p].status === 'complete') completePhases++;
var phaseLessons = PHASES[p].lessons;
if (phaseLessons.length && phaseLessons.every(function (lesson) {
var path = lesson.url && hasProgress ? window.AIFSProgress.extractPath(lesson.url) : '';
return path && window.AIFSProgress.isLessonComplete(path);
})) completePhases++;
}
return {
lessons: totalLessons,
Expand Down Expand Up @@ -113,13 +118,12 @@
var total = p.lessons.length;
var done = 0;
for (var j = 0; j < p.lessons.length; j++) {
var staticDone = p.lessons[j].status === 'complete';
var userDone = false;
if (hasProgress && p.lessons[j].url) {
var lp = window.AIFSProgress.extractPath(p.lessons[j].url);
if (lp) userDone = window.AIFSProgress.isLessonComplete(lp);
}
if (staticDone || userDone) done++;
if (userDone) done++;
}
var statusClass = p.status.replace(/ /g, '-');
var roman = toRoman(p.id);
Expand Down Expand Up @@ -152,6 +156,177 @@
}
}

function courseLessons() {
var out = [];
for (var i = 0; i < PHASES.length; i++) {
for (var j = 0; j < PHASES[i].lessons.length; j++) {
var lesson = PHASES[i].lessons[j];
var path = lesson.url && window.AIFSProgress ? window.AIFSProgress.extractPath(lesson.url) : '';
if (path) out.push({ phase: PHASES[i], lesson: lesson, path: path });
}
}
return out;
}

function renderLearningCommand() {
var card = document.getElementById('learningCommand');
if (!card || !window.AIFSProgress) return;
var lessons = courseLessons();
if (!lessons.length) return;

var state = window.AIFSProgress.getState();
var completed = 0;
for (var i = 0; i < lessons.length; i++) {
if (state.lessons[lessons[i].path] && state.lessons[lessons[i].path].completedAt) completed++;
}
var overallPct = Math.round((completed / lessons.length) * 100);
var recent = window.AIFSProgress.getMostRecentLesson();
var recentIndex = -1;
if (recent) {
for (var r = 0; r < lessons.length; r++) {
if (lessons[r].path === recent.path) { recentIndex = r; break; }
}
}

var targetIndex = recentIndex >= 0 ? recentIndex : 0;
if (recentIndex >= 0 && window.AIFSProgress.isLessonComplete(lessons[recentIndex].path)) {
for (var n = recentIndex + 1; n < lessons.length; n++) {
if (!window.AIFSProgress.isLessonComplete(lessons[n].path)) { targetIndex = n; break; }
}
}
var target = lessons[targetIndex];
var targetProgress = window.AIFSProgress.getLessonProgress(target.path);
var readingPct = Math.round(targetProgress.scrollPercent || 0);

var ring = document.getElementById('learningProgressRing');
var pctEl = document.getElementById('learningProgressPct');
var kicker = document.getElementById('learningCommandKicker');
var title = document.getElementById('learningCommandTitle');
var meta = document.getElementById('learningCommandMeta');
var link = document.getElementById('continueLearning');
if (ring) ring.style.setProperty('--progress', (overallPct * 3.6) + 'deg');
if (pctEl) pctEl.textContent = overallPct + '%';
if (kicker) kicker.textContent = recent ? 'Continue · Phase ' + String(target.phase.id).padStart(2, '0') : 'Start · Phase ' + String(target.phase.id).padStart(2, '0');
if (title) title.textContent = target.lesson.name;
if (meta) meta.textContent = completed + ' of ' + lessons.length + ' lessons completed' + (readingPct > 1 ? ' · resume at ' + readingPct + '%' : '') + '. Progress stays saved on this device.';
Comment on lines +209 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix mojibake separator in user-facing strings. The middle-dot separator was saved as corrupted UTF-8 (· instead of ·, U+00B7) and will render as garbled text in the UI. Replace each · with · (or the \u00B7 escape / &middot; where markup allows).

  • site/app.js#L209-L211: correct 'Continue · Phase ', 'Start · Phase ', and ' · resume at ' to use ·.
  • site/app.js#L240-L240: correct the 'Phase ' + ... + ' · ' + ... node title to use ·.
  • site/lesson.html#L3224-L3224: correct 'Included with this lesson · ' to use ·.
📍 Affects 2 files
  • site/app.js#L209-L211 (this comment)
  • site/app.js#L240-L240
  • site/lesson.html#L3224-L3224
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/app.js` around lines 209 - 211, Replace the corrupted · separator with
the correct middle dot in the user-facing strings at site/app.js lines 209-211
and 240, and site/lesson.html line 3224, including the Continue/Start phase
labels, resume text, node title, and Included with this lesson text.

if (link) {
link.href = 'lesson.html?path=' + encodeURIComponent(target.path) + (readingPct > 1 ? '&resume=1' : '');
link.textContent = readingPct > 1 ? 'Resume lesson' : (recent ? 'Continue learning' : 'Start course');
}
}

function initCourseUniverse() {
var scene = document.getElementById('courseUniverse');
var canvas = document.getElementById('courseUniverseCanvas');
var nodeLayer = document.getElementById('courseUniverseNodes');
if (!scene || !canvas || !nodeLayer || typeof PHASES === 'undefined') return;

var ctx = canvas.getContext('2d');
var nodes = [];
var points = [];
var rotationX = -0.18;
var rotationY = -0.45;
var dragging = false;
var previousX = 0;
var previousY = 0;
var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

for (var i = 0; i < PHASES.length; i++) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'course-universe-node phase-card';
button.setAttribute('data-phase', i);
button.setAttribute('aria-label', 'Open Phase ' + PHASES[i].id + ': ' + PHASES[i].name);
button.title = 'Phase ' + PHASES[i].id + ' · ' + PHASES[i].name;
button.textContent = 'P' + String(PHASES[i].id).padStart(2, '0');
var phaseDone = PHASES[i].lessons.length > 0 && PHASES[i].lessons.every(function (lesson) {
var path = lesson.url && window.AIFSProgress ? window.AIFSProgress.extractPath(lesson.url) : '';
return path && window.AIFSProgress.isLessonComplete(path);
});
if (phaseDone) button.classList.add('done');
nodeLayer.appendChild(button);
nodes.push(button);

var angle = (i / PHASES.length) * Math.PI * 4.2;
var radius = 175 + (i % 3) * 31;
points.push({ x: Math.cos(angle) * radius, y: (i - (PHASES.length - 1) / 2) * 19, z: Math.sin(angle) * radius });
}

function project(point) {
var cosY = Math.cos(rotationY), sinY = Math.sin(rotationY);
var x1 = point.x * cosY - point.z * sinY;
var z1 = point.x * sinY + point.z * cosY;
var cosX = Math.cos(rotationX), sinX = Math.sin(rotationX);
var y1 = point.y * cosX - z1 * sinX;
var z2 = point.y * sinX + z1 * cosX;
var scale = 650 / (650 - z2);
return { x: x1 * scale, y: y1 * scale, z: z2, scale: scale };
}

function resize() {
var rect = scene.getBoundingClientRect();
var dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

function draw() {
var rect = scene.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
var projected = points.map(project);
var styles = getComputedStyle(document.documentElement);
ctx.strokeStyle = styles.getPropertyValue('--blueprint').trim() || '#3553ff';
ctx.globalAlpha = 0.32;
ctx.lineWidth = 1;
ctx.beginPath();
for (var i = 0; i < projected.length; i++) {
var p = projected[i];
var px = rect.width / 2 + p.x;
var py = rect.height / 2 + p.y;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
ctx.globalAlpha = 1;

for (var n = 0; n < nodes.length; n++) {
var pt = projected[n];
var scale = Math.max(0.62, Math.min(1.28, pt.scale));
nodes[n].style.transform = 'translate(-50%, -50%) translate3d(' + pt.x.toFixed(1) + 'px,' + pt.y.toFixed(1) + 'px,0) scale(' + scale.toFixed(2) + ')';
nodes[n].style.opacity = String(Math.max(0.45, Math.min(1, 0.72 + pt.z / 700)));
nodes[n].style.zIndex = String(Math.round(pt.z + 300));
}
}

function frame() {
if (!dragging) rotationY += 0.0014;
draw();
if (!reduced) window.requestAnimationFrame(frame);
}

scene.addEventListener('pointerdown', function (event) {
dragging = true;
previousX = event.clientX;
previousY = event.clientY;
scene.setPointerCapture(event.pointerId);
});
scene.addEventListener('pointermove', function (event) {
if (!dragging) return;
rotationY += (event.clientX - previousX) * 0.008;
rotationX = Math.max(-0.8, Math.min(0.55, rotationX - (event.clientY - previousY) * 0.005));
previousX = event.clientX;
previousY = event.clientY;
if (reduced) draw();
});
scene.addEventListener('pointerup', function () { dragging = false; });
scene.addEventListener('pointercancel', function () { dragging = false; });
window.addEventListener('resize', function () { resize(); draw(); });
resize();
frame();
}

function toRoman(num) {
var lookup = [
['M', 1000], ['CM', 900], ['D', 500], ['CD', 400],
Expand Down Expand Up @@ -301,6 +476,7 @@
renderModalLessons(PHASES[currentPhaseIdx]);
}
populateStats();
renderLearningCommand();
renderPhases();
});
}
Expand Down
54 changes: 54 additions & 0 deletions site/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,56 @@ function discoverArtifacts() {
// ─── Main build ──────────────────────────────────────────────────────
// Write the git ref this deploy was built from, so lesson.html fetches docs
// from the right branch (PR previews render their own edits, not main).
function listMaterialFiles(absDir, publicDir) {
if (!fs.existsSync(absDir)) return [];
const files = [];
function walk(dir, publicBase) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
const href = `${publicBase}/${entry.name}`.replace(/\\/g, '/');
if (entry.isDirectory()) walk(abs, href);
else if (entry.name !== '.gitkeep') files.push({ name: entry.name, path: href, size: fs.statSync(abs).size });
}
}
walk(absDir, publicDir);
return files;
}

function collectMaterials(phases) {
const materials = {};
for (const phase of phases) {
for (const lesson of phase.lessons) {
if (!lesson.url) continue;
const rel = lesson.url.replace(GITHUB_BASE, '').replace(/\/+$/, '');
const abs = path.join(REPO_ROOT, rel);
materials[rel] = {
lesson: fs.existsSync(path.join(abs, 'docs', 'en.md')) ? `course/${rel}/docs/en.md` : '',
quiz: fs.existsSync(path.join(abs, 'quiz.json')) ? `course/${rel}/quiz.json` : '',
code: listMaterialFiles(path.join(abs, 'code'), `course/${rel}/code`),
outputs: listMaterialFiles(path.join(abs, 'outputs'), `course/${rel}/outputs`),
extras: listMaterialFiles(abs, `course/${rel}`).filter(file => !file.path.includes('/docs/') && !file.path.includes('/code/') && !file.path.includes('/outputs/') && !file.path.endsWith('/quiz.json')),
};
}
}
return materials;
}

function syncCourseContent() {
const target = path.join(__dirname, 'course');
if (!target.startsWith(__dirname + path.sep)) throw new Error('Unsafe course output path');
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(target, { recursive: true });
for (const dir of ['phases', 'projects', 'outputs', 'glossary']) {
const source = path.join(REPO_ROOT, dir);
if (fs.existsSync(source)) fs.cpSync(source, path.join(target, dir), { recursive: true });
}
for (const file of ['README.md', 'ROADMAP.md', 'LICENSE']) {
const source = path.join(REPO_ROOT, file);
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(target, file));
}
console.log(' bundled complete course content');
}

function writeBuildMeta() {
let ref = process.env.VERCEL_GIT_COMMIT_REF || '';
if (!ref) {
Expand Down Expand Up @@ -449,6 +499,8 @@ function build() {
}
}

const materials = collectMaterials(phases);

// Stats
let totalLessons = 0;
let completeLessons = 0;
Expand Down Expand Up @@ -477,6 +529,8 @@ const ARTIFACTS = ${JSON.stringify(artifacts, null, 2)};
`;

fs.writeFileSync(OUTPUT_PATH, output, 'utf8');
fs.writeFileSync(path.join(__dirname, 'materials.js'), '// Auto-generated by build.js - do not edit.\nwindow.MATERIALS = ' + JSON.stringify(materials) + ';\n', 'utf8');
syncCourseContent();
console.log(`\n✅ Generated ${OUTPUT_PATH}`);

syncCounts(totalLessons, phases.length, artifacts.length);
Expand Down
Loading