' in html_content
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_preprocessor.py::test_process_slide_full -v`
+Expected: FAIL — `ImportError`
+
+- [ ] **Step 3: Implement `process_slide`**
+
+Add to `preprocessor.py`:
+```python
+import markdown
+
+
+def process_slide(slide_md: str) -> tuple[str, list[str]]:
+ """Full preprocessing pipeline for a single slide.
+
+ Returns (rendered_html, css_classes).
+ """
+ # 1. Extract class directive
+ content, classes = strip_class_directive(slide_md)
+
+ # 2. Strip presenter notes
+ content = strip_presenter_notes(content)
+
+ # 3. Strip incremental reveals
+ content = strip_incremental_reveals(content)
+
+ # 4. Convert .classname[] wrappers
+ content = convert_class_wrappers(content)
+
+ # 5. Render markdown to HTML
+ # md_in_html extension allows markdown inside
blocks
+ # produced by convert_class_wrappers for block-level .classname[] usage
+ md = markdown.Markdown(
+ extensions=["fenced_code", "codehilite", "tables", "md_in_html"],
+ extension_configs={
+ "codehilite": {"css_class": "highlight", "guess_lang": False}
+ },
+ )
+ html = md.convert(content)
+
+ return html, classes
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `python -m pytest tests/test_preprocessor.py -v`
+Expected: All tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add preprocessor.py tests/test_preprocessor.py
+git commit -m "add full slide preprocessing pipeline"
+```
+
+---
+
+### Task 7: Image Path Rewriting
+
+**Files:**
+- Modify: `preprocessor.py`
+- Modify: `tests/test_preprocessor.py`
+
+- [ ] **Step 1: Write failing tests**
+
+Append to `tests/test_preprocessor.py`:
+```python
+from preprocessor import rewrite_image_paths
+
+
+def test_rewrite_relative_image_paths():
+ html = '

'
+ result = rewrite_image_paths(html)
+ assert 'src="/lecture_notes/images/photo.png"' in result
+
+
+def test_rewrite_leaves_absolute_paths():
+ html = '

'
+ result = rewrite_image_paths(html)
+ assert 'src="/examples/week_1/image.png"' in result
+
+
+def test_rewrite_leaves_external_urls():
+ html = '

'
+ result = rewrite_image_paths(html)
+ assert 'src="https://imgs.xkcd.com/comics/exploits.png"' in result
+
+
+def test_rewrite_does_not_touch_script_src():
+ html = ''
+ result = rewrite_image_paths(html)
+ assert result == html
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `python -m pytest tests/test_preprocessor.py -v -k "rewrite"`
+Expected: FAIL — `ImportError`
+
+- [ ] **Step 3: Implement `rewrite_image_paths`**
+
+Add to `preprocessor.py`:
+```python
+def rewrite_image_paths(html: str) -> str:
+ """Rewrite relative image src paths to absolute /lecture_notes/ paths.
+ Only affects
![]()
tags, not
+{% endblock %}
+```
+
+- [ ] **Step 3: Create `templates/page.html`**
+
+```html
+{% extends "base.html" %}
+
+{% block title %}{{ title }} | Web Development{% endblock %}
+
+{% block content %}
+
+ {{ content }}
+
+{% endblock %}
+```
+
+- [ ] **Step 4: Create `templates/index.html`**
+
+```html
+{% extends "base.html" %}
+
+{% block title %}Web Development — MPCS 52553{% endblock %}
+
+{% block content %}
+
+
Web Development
+
MPCS 52553 — Spring 2026
+
+
+ Course materials are on
+ GitHub.
+ Join #web-development on
+ UChicago CS Slack.
+
+
+{% endblock %}
+```
+
+- [ ] **Step 5: Create `templates/lecture_index.html`**
+
+```html
+{% extends "base.html" %}
+
+{% block title %}Lecture Notes | Web Development{% endblock %}
+
+{% block content %}
+
+{% endblock %}
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add templates/
+git commit -m "add Jinja2 templates for slides, pages, and indexes"
+```
+
+---
+
+### Task 9: CSS Stylesheet
+
+**Files:**
+- Create: `static/css/style.css`
+
+- [ ] **Step 1: Create `static/css/style.css`**
+
+Port relevant rules from `lecture_notes/styles/style.css`, replacing `.remark-*` selectors with new structure. Key sections:
+
+```css
+/* Fonts */
+@import url(https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz);
+@import url(https://fonts.googleapis.com/css?family=Droid+Serif:400,700,400italic);
+@import url(https://fonts.googleapis.com/css?family=Droid+Sans:400,700,400italic);
+@import url(https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700,400italic);
+
+/* Base */
+body {
+ font-family: "Droid Serif", serif;
+ background-color: white;
+ margin: 0;
+ min-width: 15em;
+}
+
+/* Navigation header */
+#header {
+ background-color: #333;
+ font-family: "Droid Sans", sans-serif;
+ padding: 0;
+}
+#header ul {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+#header li {
+ padding: 0.25em 0.75em;
+}
+#header a, #header a:visited {
+ color: white;
+ text-decoration: none;
+}
+#header a:hover {
+ color: #ccc;
+}
+
+/* Slide navigation bar */
+.slide-nav {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5em;
+ gap: 1em;
+ font-family: "Droid Sans", sans-serif;
+ border-bottom: 1px solid #eee;
+}
+.slide-nav a {
+ text-decoration: none;
+ color: #333;
+ font-size: 1.5em;
+}
+.slide-nav .disabled {
+ color: #ccc;
+}
+.slide-info {
+ font-size: 0.9em;
+ color: #666;
+}
+
+/* Slide content area */
+.slide-container {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 1em 2em;
+}
+.slide-content {
+ padding: 1em 0;
+}
+
+/* Page content (syllabus, etc.) */
+.page-content {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 1em 2em;
+}
+
+/* Homepage */
+.homepage {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 2em;
+}
+
+/* Lecture index */
+.lecture-index {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 1em 2em;
+}
+
+/* Typography */
+h1, h2, h3 {
+ font-family: "Yanone Kaffeesatz", sans-serif;
+ font-weight: normal;
+}
+
+/* Remark class ports */
+.center { text-align: center; }
+.middle { display: flex; flex-direction: column; justify-content: center; min-height: 60vh; }
+
+.big { font-size: xx-large; font-family: "Yanone Kaffeesatz", sans-serif; }
+.fancyStrong strong { font-size: xx-large; font-family: "Yanone Kaffeesatz", sans-serif; }
+
+.credit {
+ display: block;
+ color: #aaa;
+ text-decoration: none;
+ font-size: 0.75em;
+ text-align: center;
+ padding: 0.25em;
+}
+.credit a { color: #aaa; }
+
+.half img { max-width: 40%; max-height: 40%; }
+
+.split ul {
+ list-style-type: none;
+ display: flex;
+ padding: 0;
+}
+.split ul li {
+ width: auto;
+ max-width: 70%;
+}
+.split ul li img { max-width: 100%; }
+
+.agenda h1 { margin-bottom: 0; }
+.agenda ul { margin-block-start: 0; color: #666; }
+
+.overlay p { position: relative; z-index: 1; padding: 1em; background: rgba(255,255,255,0.8); }
+.overlay p:has(img) { z-index: 0; width: 100%; float: right; margin-bottom: -100%; }
+
+.gallery p:has(img) { display: flex; flex-wrap: wrap; justify-content: center; }
+.gallery p:has(img) img { height: 8em; margin: 1em; }
+
+.gallery-big p:has(img) { display: flex; flex-wrap: wrap; justify-content: center; }
+.gallery-big p:has(img) img { height: 16em; margin: 1em; }
+
+.animate img {
+ width: 210px;
+ animation-duration: 3s;
+ animation-name: rise;
+}
+@keyframes rise {
+ from { margin-top: 60%; margin-left: 100%; }
+ to { margin-top: 0%; }
+}
+
+.highlight-third-code-line .highlight pre code .line:nth-child(3) {
+ font-weight: bold;
+}
+
+/* Images */
+img {
+ display: block;
+ max-width: 80%;
+ max-height: 80%;
+}
+
+/* Blockquotes */
+blockquote p {
+ border-left: 0.5em solid lightgray;
+ padding-left: 0.5em;
+}
+blockquote footer {
+ font-size: small;
+ color: gray;
+}
+
+/* Code blocks */
+code, pre {
+ font-family: "Ubuntu Mono", monospace;
+}
+code {
+ background-color: lightblue;
+ padding: 2px 5px;
+}
+pre code {
+ display: block;
+ padding: 1em;
+ overflow-x: auto;
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+mkdir -p static/css
+git add static/css/style.css
+git commit -m "add site stylesheet ported from remark CSS"
+```
+
+---
+
+### Task 10: Navigation JavaScript
+
+**Files:**
+- Create: `static/js/navigation.js`
+
+- [ ] **Step 1: Create `static/js/navigation.js`**
+
+```javascript
+document.addEventListener("DOMContentLoaded", function () {
+ const container = document.querySelector(".slide-container");
+ if (!container) return;
+
+ const prevUrl = container.dataset.prev;
+ const nextUrl = container.dataset.next;
+ const firstUrl = container.dataset.first;
+
+ document.addEventListener("keydown", function (e) {
+ // Don't navigate if user is typing in an input
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
+
+ switch (e.key) {
+ case "ArrowRight":
+ case "ArrowDown":
+ if (nextUrl) window.location.href = nextUrl;
+ break;
+ case "ArrowLeft":
+ case "ArrowUp":
+ if (prevUrl) window.location.href = prevUrl;
+ break;
+ case "Home":
+ if (firstUrl) window.location.href = firstUrl;
+ e.preventDefault();
+ break;
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+mkdir -p static/js
+git add static/js/navigation.js
+git commit -m "add keyboard navigation for slides"
+```
+
+---
+
+### Task 11: Build Script
+
+**Files:**
+- Create: `build.py`
+- Create: `tests/test_build.py`
+
+- [ ] **Step 1: Write integration test**
+
+`tests/test_build.py`:
+```python
+import os
+import shutil
+from pathlib import Path
+from build import build_site
+
+
+def test_build_produces_output(tmp_path, monkeypatch):
+ """Smoke test: build produces _site/ with expected structure."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"))
+ site = tmp_path / "_site"
+
+ assert (site / "index.html").exists()
+ assert (site / "syllabus" / "index.html").exists()
+ assert (site / "lecture_notes" / "index.html").exists()
+ assert (site / "lecture_notes" / "week_1" / "1" / "index.html").exists()
+ assert (site / "css" / "style.css").exists()
+ assert (site / "js" / "navigation.js").exists()
+ assert (site / "examples").is_dir()
+ assert (site / "lecture_notes" / "images").is_dir()
+
+
+def test_build_slide_has_navigation(tmp_path, monkeypatch):
+ """Slide pages include navigation data attributes."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"))
+
+ slide = (tmp_path / "_site" / "lecture_notes" / "week_1" / "2" / "index.html").read_text()
+ assert 'data-prev=' in slide
+ assert 'data-next=' in slide
+ assert 'data-slide-num="2"' in slide
+
+
+def test_build_no_week_8_old(tmp_path, monkeypatch):
+ """week_8_old.md should not produce output."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"))
+
+ assert not (tmp_path / "_site" / "lecture_notes" / "week_8_old").exists()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `python -m pytest tests/test_build.py -v`
+Expected: FAIL — `ImportError: cannot import name 'build_site'`
+
+- [ ] **Step 3: Implement `build.py`**
+
+```python
+import glob
+import os
+import re
+import shutil
+from pathlib import Path
+
+from jinja2 import Environment, FileSystemLoader
+import markdown
+
+from preprocessor import split_slides, process_slide, rewrite_image_paths
+
+
+def build_site(output_dir: str = "_site"):
+ """Build the entire static site."""
+ root = Path(__file__).parent
+ out = Path(output_dir)
+
+ # Clean output
+ if out.exists():
+ shutil.rmtree(out)
+ out.mkdir(parents=True)
+
+ # Set up Jinja2
+ env = Environment(loader=FileSystemLoader(root / "templates"))
+
+ # Copy static assets
+ shutil.copytree(root / "static" / "css", out / "css")
+ shutil.copytree(root / "static" / "js", out / "js")
+ shutil.copytree(root / "examples", out / "examples")
+ shutil.copytree(root / "lecture_notes" / "images", out / "lecture_notes" / "images")
+
+ # Find week files (exclude _old files)
+ week_files = sorted(glob.glob(str(root / "lecture_notes" / "week_*.md")))
+ week_files = [f for f in week_files if "_old" not in f]
+
+ weeks = []
+
+ for week_file in week_files:
+ week_num = int(re.search(r"week_(\d+)", week_file).group(1))
+ weeks.append(week_num)
+
+ md_content = Path(week_file).read_text()
+ slide_strings = split_slides(md_content)
+
+ total_slides = len(slide_strings)
+ week_dir = out / "lecture_notes" / f"week_{week_num}"
+
+ for i, slide_md in enumerate(slide_strings, 1):
+ html_content, classes = process_slide(slide_md)
+ html_content = rewrite_image_paths(html_content)
+
+ prev_url = f"/lecture_notes/week_{week_num}/{i - 1}/" if i > 1 else ""
+ next_url = f"/lecture_notes/week_{week_num}/{i + 1}/" if i < total_slides else ""
+ first_url = f"/lecture_notes/week_{week_num}/1/"
+
+ slide_html = env.get_template("slide.html").render(
+ content=html_content,
+ classes=classes,
+ week=week_num,
+ slide_num=i,
+ total_slides=total_slides,
+ prev_url=prev_url,
+ next_url=next_url,
+ first_url=first_url,
+ )
+
+ slide_dir = week_dir / str(i)
+ slide_dir.mkdir(parents=True, exist_ok=True)
+ (slide_dir / "index.html").write_text(slide_html)
+
+ # Lecture notes index
+ lecture_index_html = env.get_template("lecture_index.html").render(weeks=weeks)
+ lectures_dir = out / "lecture_notes"
+ lectures_dir.mkdir(parents=True, exist_ok=True)
+ (lectures_dir / "index.html").write_text(lecture_index_html)
+
+ # Syllabus
+ syllabus_md = (root / "syllabus.md").read_text()
+ md_renderer = markdown.Markdown(
+ extensions=["fenced_code", "codehilite", "tables"],
+ extension_configs={
+ "codehilite": {"css_class": "highlight", "guess_lang": False}
+ },
+ )
+ syllabus_html = md_renderer.convert(syllabus_md)
+ page_html = env.get_template("page.html").render(
+ title="Syllabus", content=syllabus_html
+ )
+ syllabus_dir = out / "syllabus"
+ syllabus_dir.mkdir(parents=True, exist_ok=True)
+ (syllabus_dir / "index.html").write_text(page_html)
+
+ # Homepage
+ homepage_html = env.get_template("index.html").render()
+ (out / "index.html").write_text(homepage_html)
+
+
+if __name__ == "__main__":
+ build_site()
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `python -m pytest tests/test_build.py -v`
+Expected: All 3 tests PASS.
+
+- [ ] **Step 5: Run full build and check output**
+
+Run: `python build.py && python -m http.server -d _site 8080 &`
+
+Manually verify:
+- `http://localhost:8080/` — homepage loads
+- `http://localhost:8080/lecture_notes/` — week listing
+- `http://localhost:8080/lecture_notes/week_1/1/` — first slide, arrow keys work
+- `http://localhost:8080/syllabus/` — syllabus renders
+- `http://localhost:8080/examples/` — examples directory listing
+- Images display correctly on slides
+
+- [ ] **Step 6: Add `_site/` to `.gitignore`**
+
+Append to `.gitignore`:
+```
+_site/
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add build.py tests/test_build.py .gitignore
+git commit -m "add build script for static site generation"
+```
+
+---
+
+### Task 12: GitHub Actions Workflow
+
+**Files:**
+- Create: `.github/workflows/build-and-deploy.yml`
+
+- [ ] **Step 1: Create workflow file**
+
+`.github/workflows/build-and-deploy.yml`:
+```yaml
+name: Build and Deploy
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install -r requirements.txt
+ - run: python build.py
+ - 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
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add .github/workflows/build-and-deploy.yml
+git commit -m "add GitHub Actions workflow for Pages deployment"
+```
+
+---
+
+### Task 13: End-to-End Verification
+
+- [ ] **Step 1: Run all tests**
+
+Run: `python -m pytest tests/ -v`
+Expected: All tests pass.
+
+- [ ] **Step 2: Build the site**
+
+Run: `python build.py`
+Expected: `_site/` directory created with all expected files.
+
+- [ ] **Step 3: Manual browser check**
+
+Run: `python -m http.server -d _site 8080`
+
+Check:
+- Homepage renders and links work
+- Lecture notes index lists all weeks
+- Slides render with correct content (no remark syntax visible)
+- Keyboard navigation works (arrow keys, Home)
+- Images display correctly
+- Code blocks have syntax highlighting
+- Syllabus page renders
+- Examples directory is browsable
+- CSS classes from remark (`.big`, `.half`, `.credit`, `.split`, etc.) render correctly
+
+- [ ] **Step 4: Stop server, final commit if any fixes needed**
diff --git a/docs/specs/2026-04-01-static-site-generation-design.md b/docs/specs/2026-04-01-static-site-generation-design.md
new file mode 100644
index 0000000..b8432d0
--- /dev/null
+++ b/docs/specs/2026-04-01-static-site-generation-design.md
@@ -0,0 +1,129 @@
+# Static Site Generation Migration
+
+## Overview
+
+Migrate the course materials site from client-side remarkjs rendering to a Python-based static site generator that produces one HTML page per slide. The generator uses Jinja2 for templates and python-markdown for Markdown rendering. Output is static HTML suitable for GitHub Pages or any static host.
+
+## Markdown Processing Pipeline
+
+The build script reads each `lecture_notes/week_N.md` (excluding `week_8_old.md`) and processes it through:
+
+1. **Split on `---`** — split the file into individual slide strings. The `---` must appear on its own line to count as a slide boundary.
+
+2. **Strip remark syntax** from each slide — a preprocessor that:
+ - Removes `class: ...` directives at the top of slides. These classes (e.g., `center`, `middle`, `agenda`, `split`) are applied as CSS classes on the slide's container `
` in the output HTML, so their visual effect can be preserved via the stylesheet.
+ - Converts `.classname[content]` wrappers to `
content` (or `
` for block-level content). This preserves classes like `.big`, `.half`, `.credit`, `.gallery` so they can be styled via CSS.
+ - Removes `???` presenter note blocks — everything from a line containing only `???` (outside of fenced code blocks) to the end of the slide.
+ - Removes `--` incremental reveal markers — only when `--` appears as the sole content on a line, outside of fenced code blocks.
+
+3. **Render Markdown to HTML** — each slide's markdown is run through python-markdown with extensions for fenced code blocks, syntax highlighting (codehilite/Pygments), and tables.
+
+4. **Render into Jinja2 template** — the slide HTML is injected into a `slide.html` template with navigation data, prev/next links, and site CSS.
+
+## Images and Asset Paths
+
+- `lecture_notes/images/` is copied to `_site/lecture_notes/images/`.
+- Image references in slide markdown use relative paths like `images/foo.png`. Since slides output to `_site/lecture_notes/week_N/1/index.html`, these relative paths will not resolve correctly. The build script rewrites image `src` attributes in generated slide HTML to use absolute paths (e.g., `/lecture_notes/images/foo.png`).
+- Absolute links in the markdown (e.g., `/examples/week_2/columns.html`) are left as-is — they resolve correctly from the site root.
+- External image URLs (e.g., xkcd, imgflip) are left as-is.
+
+## Output Structure
+
+```
+_site/
+├── index.html # Course homepage
+├── syllabus/
+│ └── index.html # Rendered syllabus
+├── lecture_notes/
+│ ├── index.html # Week listing page
+│ ├── images/ # Copied from lecture_notes/images/
+│ └── week_1/
+│ ├── 1/index.html # Slide 1
+│ ├── 2/index.html # Slide 2
+│ └── ...
+│ └── week_2/
+│ └── ...
+├── examples/ # Copied as-is (including examples/images/)
+│ ├── week_1/
+│ └── ...
+├── css/
+│ └── style.css # Site stylesheet
+└── js/
+ └── navigation.js # Keyboard nav
+```
+
+- Clean URLs via `index.html` inside directories (e.g., `/lecture_notes/week_1/3/`)
+- Examples directory copied verbatim, including `examples/images/`
+- CSS and JS are shared across all pages
+- `trevor.html` and `yourname.html` from the repo root are not included in the output (student exercise artifacts)
+
+## Navigation
+
+A vanilla JS file (`navigation.js`) handles keyboard navigation:
+
+- **Left/Up arrow** — previous slide
+- **Right/Down arrow** — next slide
+- **Home** — first slide of the current week
+
+Each slide's HTML includes data attributes with prev/next URLs and slide count. The JS reads these and navigates via `window.location`.
+
+Each slide page also has a visible nav bar:
+- Week title and slide number (e.g., "Week 3 — 12 / 34")
+- Clickable prev/next arrows
+- Link back to course homepage
+
+## Templates
+
+Four Jinja2 templates:
+
+1. **`base.html`** — shared shell: ``, CSS link, nav bar, JS link, `{% block content %}`
+2. **`slide.html`** — extends base. Renders a single slide's HTML content with navigation data (prev/next URLs, slide number, total count, week number).
+3. **`page.html`** — extends base. For non-slide pages (syllabus). No slide navigation, just content.
+4. **`lecture_index.html`** — extends base. Lists all weeks with links to their first slide. Generated at `/lecture_notes/index.html`.
+
+The homepage is a Jinja2 template rendered directly (`templates/index.html`), not generated from markdown, since it has custom layout.
+
+## Stylesheet
+
+The new `css/style.css` is written from scratch but ports relevant visual rules from the existing `lecture_notes/styles/style.css`:
+- Slide container classes preserved from `class:` directives (`center`, `middle`, `split`, `agenda`, `gallery`, etc.)
+- Inline content classes preserved from `.classname[]` syntax (`big`, `half`, `credit`, `animate`, etc.)
+- Typography (existing Google Fonts: Yanone Kaffeesatz, Droid Serif, Ubuntu Mono)
+- Code block styling (Pygments theme)
+
+## Build Script
+
+**`build.py`** — single entry point. `python build.py`:
+
+1. Cleans and recreates `_site/`
+2. Walks `lecture_notes/week_*.md` (skipping `*_old.md`), runs each through the split/preprocess/render pipeline
+3. Generates the lecture notes index page
+4. Renders the homepage and syllabus (from `syllabus.md` at the repo root, using the `page.html` template)
+5. Copies `examples/`, `lecture_notes/images/`, and static assets (CSS, JS) into `_site/`
+
+**Local preview:** `python -m http.server -d _site`
+
+## Dependencies
+
+In `requirements.txt`:
+- `Jinja2` — templating
+- `Markdown` — python-markdown for rendering
+- `Pygments` — syntax highlighting for code blocks
+
+## Deployment
+
+- Output is a static `_site/` directory deployable anywhere
+- Primary target: GitHub Pages via GitHub Actions (build on push, deploy to Pages)
+- Alternative: rsync or any static file host
+- The existing `deploy.sh` (rsync to uchicagowebdev.com) is superseded but can be kept for backwards compatibility during transition
+
+## Cleanup
+
+After migration is complete, the following files become unused and can be removed:
+- `lecture_notes/scripts/` (remark-v0.15.0.min.js, require.js, slides.js)
+- `lecture_notes/styles/` (replaced by `css/style.css`)
+- `lecture_notes/index.html` (replaced by generated output)
+
+## Markdown File Organization
+
+Markdown files stay as one file per week (`week_N.md`). The build system splits on `---` to produce individual slide pages. This keeps authoring simple and the files human-readable.
diff --git a/preprocessor.py b/preprocessor.py
new file mode 100644
index 0000000..1f43e6a
--- /dev/null
+++ b/preprocessor.py
@@ -0,0 +1,188 @@
+import re
+
+import markdown
+
+
+def split_slides(markdown: str) -> list[str]:
+ """Split markdown on --- slide boundaries, ignoring --- inside fenced code blocks."""
+ slides = []
+ current_slide = []
+ in_code_block = False
+
+ for line in markdown.split("\n"):
+ stripped = line.strip()
+
+ if stripped.startswith("```"):
+ in_code_block = not in_code_block
+
+ if stripped == "---" and not in_code_block:
+ slides.append("\n".join(current_slide))
+ current_slide = []
+ else:
+ current_slide.append(line)
+
+ if current_slide:
+ slides.append("\n".join(current_slide))
+
+ return [s.strip() for s in slides if s.strip()]
+
+
+def strip_class_directive(slide: str) -> tuple[str, list[str]]:
+ """Remove 'class: ...' directive from top of slide. Returns (content, class_list)."""
+ lines = slide.split("\n")
+ if lines and lines[0].strip().startswith("class:"):
+ class_line = lines[0].strip()
+ class_str = class_line[len("class:"):].strip()
+ classes = [c.strip() for c in class_str.split(",")]
+ return "\n".join(lines[1:]), classes
+ return slide, []
+
+
+def strip_presenter_notes(slide: str) -> str:
+ """Remove everything from a standalone ??? line to end of slide, outside code blocks."""
+ lines = slide.split("\n")
+ result = []
+ in_code_block = False
+
+ for line in lines:
+ stripped = line.strip()
+
+ if stripped.startswith("```"):
+ in_code_block = not in_code_block
+
+ if stripped == "???" and not in_code_block:
+ break
+
+ result.append(line)
+
+ return "\n".join(result).rstrip()
+
+
+def strip_incremental_reveals(slide: str) -> str:
+ """Remove standalone -- lines (incremental reveal markers) outside code blocks."""
+ lines = slide.split("\n")
+ result = []
+ in_code_block = False
+
+ for line in lines:
+ stripped = line.strip()
+
+ if stripped.startswith("```"):
+ in_code_block = not in_code_block
+
+ if stripped == "--" and not in_code_block:
+ continue
+
+ result.append(line)
+
+ return "\n".join(result)
+
+
+def convert_class_wrappers(text: str) -> str:
+ """Convert .classname[content] remark syntax to HTML elements.
+
+ Block-level usage (line starts with .classname[) becomes
.
+ Inline usage becomes
.
+ Handles nested brackets (e.g., markdown links inside wrappers).
+ Handles multiline blocks where ] is on a subsequent line.
+ """
+ result = _convert_multiline_wrappers(text)
+ result = _convert_inline_wrappers(result)
+ return result
+
+
+def _convert_multiline_wrappers(text: str) -> str:
+ """Handle .classname[...] that spans multiple lines."""
+ lines = text.split("\n")
+ result = []
+ i = 0
+
+ while i < len(lines):
+ match = re.match(r"^\.(\w[\w-]*)\[\s*$", lines[i].strip())
+ if match:
+ classname = match.group(1)
+ inner_lines = []
+ i += 1
+ while i < len(lines) and lines[i].strip() != "]":
+ inner_lines.append(lines[i])
+ i += 1
+ inner = "\n".join(inner_lines).strip()
+ result.append(f'\n\n{inner}\n\n
')
+ i += 1 # Skip the closing ]
+ else:
+ result.append(lines[i])
+ i += 1
+
+ return "\n".join(result)
+
+
+def _convert_inline_wrappers(text: str) -> str:
+ """Handle .classname[...] on a single line."""
+ def replace_match(match):
+ classname = match.group(1)
+ content = match.group(2)
+ line_before = match.string[:match.start()]
+ line_after = match.string[match.end():]
+
+ # Block-level only if .classname[...] is the entire line content:
+ # nothing before it (on this line) and nothing after it (on this line)
+ last_newline = line_before.rfind("\n")
+ line_start = line_before[last_newline + 1:] if last_newline >= 0 else line_before
+
+ next_newline = line_after.find("\n")
+ line_end = line_after[:next_newline] if next_newline >= 0 else line_after
+
+ if line_start.strip() == "" and line_end.strip() == "":
+ # Block-level: use markdown="1" so python-markdown renders inner content
+ return f'\n\n{content}\n\n
'
+ else:
+ return f'{content}'
+
+ # Match .classname[content] handling nested brackets
+ pattern = r"\.(\w[\w-]*)\[((?:[^\[\]]*|\[(?:[^\[\]]*|\[[^\[\]]*\])*\])*)\]"
+ return re.sub(pattern, replace_match, text)
+
+
+def process_slide(slide_md: str) -> tuple[str, list[str]]:
+ """Full preprocessing pipeline for a single slide.
+
+ Returns (rendered_html, css_classes).
+ """
+ # 1. Extract class directive
+ content, classes = strip_class_directive(slide_md)
+
+ # 2. Strip presenter notes
+ content = strip_presenter_notes(content)
+
+ # 3. Strip incremental reveals
+ content = strip_incremental_reveals(content)
+
+ # 4. Convert .classname[] wrappers
+ content = convert_class_wrappers(content)
+
+ # 5. Render markdown to HTML
+ # md_in_html extension allows markdown inside blocks
+ # produced by convert_class_wrappers for block-level .classname[] usage
+ md = markdown.Markdown(
+ extensions=["fenced_code", "codehilite", "tables", "md_in_html"],
+ extension_configs={
+ "codehilite": {"css_class": "highlight", "guess_lang": False}
+ },
+ )
+ html = md.convert(content)
+
+ return html, classes
+
+
+def rewrite_image_paths(html: str, base_url: str = "") -> str:
+ """Rewrite relative image src paths to absolute /lecture_notes/ paths.
+ Only affects
![]()
tags, not
+{% endblock %}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_build.py b/tests/test_build.py
new file mode 100644
index 0000000..df0f595
--- /dev/null
+++ b/tests/test_build.py
@@ -0,0 +1,58 @@
+from pathlib import Path
+from build import build_site
+
+
+def test_build_produces_output(tmp_path, monkeypatch):
+ """Smoke test: build produces _site/ with expected structure."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"))
+ site = tmp_path / "_site"
+
+ assert (site / "index.html").exists()
+ assert (site / "syllabus" / "index.html").exists()
+ assert (site / "lecture_notes" / "index.html").exists()
+ assert (site / "lecture_notes" / "week_1" / "1" / "index.html").exists()
+ assert (site / "css" / "style.css").exists()
+ assert (site / "js" / "navigation.js").exists()
+ assert (site / "examples").is_dir()
+ assert (site / "lecture_notes" / "images").is_dir()
+
+
+def test_build_slide_has_navigation(tmp_path, monkeypatch):
+ """Slide pages include navigation data attributes."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"))
+
+ slide = (tmp_path / "_site" / "lecture_notes" / "week_1" / "2" / "index.html").read_text()
+ assert 'data-prev=' in slide
+ assert 'data-next=' in slide
+ assert 'data-slide-num="2"' in slide
+
+
+def test_build_no_week_8_old(tmp_path, monkeypatch):
+ """week_8_old.md should not produce output."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"))
+
+ assert not (tmp_path / "_site" / "lecture_notes" / "week_8_old").exists()
+
+
+def test_build_base_url(tmp_path, monkeypatch):
+ """base_url is prefixed on all internal links."""
+ monkeypatch.chdir(Path(__file__).parent.parent)
+ build_site(output_dir=str(tmp_path / "_site"), base_url="/course_materials")
+ site = tmp_path / "_site"
+
+ # Homepage links use base_url
+ homepage = (site / "index.html").read_text()
+ assert 'href="/course_materials/syllabus/"' in homepage
+
+ # Slide navigation uses base_url
+ slide = (site / "lecture_notes" / "week_1" / "2" / "index.html").read_text()
+ assert 'data-prev="/course_materials/lecture_notes/week_1/1/"' in slide
+ assert "/course_materials/js/navigation.js" in slide
+ assert "/course_materials/css/style.css" in slide
+
+ # Lecture index uses base_url
+ lecture_index = (site / "lecture_notes" / "index.html").read_text()
+ assert "/course_materials/lecture_notes/week_1/1/" in lecture_index
diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py
new file mode 100644
index 0000000..e2216be
--- /dev/null
+++ b/tests/test_preprocessor.py
@@ -0,0 +1,168 @@
+from preprocessor import split_slides, strip_class_directive, strip_presenter_notes, strip_incremental_reveals, convert_class_wrappers, process_slide, rewrite_image_paths
+
+
+def test_split_slides_basic():
+ md = "# Slide 1\nContent\n---\n# Slide 2\nMore content"
+ slides = split_slides(md)
+ assert len(slides) == 2
+ assert "# Slide 1" in slides[0]
+ assert "# Slide 2" in slides[1]
+
+
+def test_split_slides_ignores_hr_in_code_block():
+ md = "# Slide 1\n```\n---\n```\n---\n# Slide 2"
+ slides = split_slides(md)
+ assert len(slides) == 2
+ assert "---" in slides[0]
+
+
+def test_split_slides_trims_whitespace():
+ md = "# Slide 1\n\n---\n\n# Slide 2\n"
+ slides = split_slides(md)
+ assert slides[0].strip() == "# Slide 1"
+ assert slides[1].strip() == "# Slide 2"
+
+
+def test_strip_class_directive_basic():
+ slide = "class: center, middle\n# Title"
+ content, classes = strip_class_directive(slide)
+ assert content.strip() == "# Title"
+ assert classes == ["center", "middle"]
+
+
+def test_strip_class_directive_none():
+ slide = "# No class here"
+ content, classes = strip_class_directive(slide)
+ assert content.strip() == "# No class here"
+ assert classes == []
+
+
+def test_strip_class_directive_single():
+ slide = "class: agenda\n# Agenda Items"
+ content, classes = strip_class_directive(slide)
+ assert content.strip() == "# Agenda Items"
+ assert classes == ["agenda"]
+
+
+def test_strip_presenter_notes_basic():
+ slide = "# Slide\nContent\n\n???\n\nThese are notes"
+ result = strip_presenter_notes(slide)
+ assert "Content" in result
+ assert "notes" not in result
+ assert "???" not in result
+
+
+def test_strip_presenter_notes_no_notes():
+ slide = "# Slide\nContent"
+ result = strip_presenter_notes(slide)
+ assert result == slide
+
+
+def test_strip_presenter_notes_ignores_code_block():
+ slide = "# Slide\n```\n???\n```\nMore content"
+ result = strip_presenter_notes(slide)
+ assert "???" in result
+ assert "More content" in result
+
+
+def test_strip_incremental_reveals():
+ slide = "# Slide\nFirst point\n\n--\n\nSecond point"
+ result = strip_incremental_reveals(slide)
+ assert "First point" in result
+ assert "Second point" in result
+ assert "\n--\n" not in result
+
+
+def test_strip_incremental_reveals_ignores_code_block():
+ slide = "```\n--\n```"
+ result = strip_incremental_reveals(slide)
+ assert "--" in result
+
+
+def test_convert_class_wrappers_inline():
+ text = "Some .big[**bold text**] here"
+ result = convert_class_wrappers(text)
+ assert result == 'Some
**bold text** here'
+
+
+def test_convert_class_wrappers_line_start_with_trailing_text():
+ """A .classname[] at line start with trailing text is inline, not block."""
+ text = ".big[**0.1 second**] is about the limit for the user"
+ result = convert_class_wrappers(text)
+ assert result == '
**0.1 second** is about the limit for the user'
+
+
+def test_convert_class_wrappers_block_whole_line():
+ text = ".half[]"
+ result = convert_class_wrappers(text)
+ assert result == '
\n\n\n\n
'
+
+
+def test_convert_class_wrappers_credit():
+ text = ".credit[https://xkcd.com/327/]"
+ result = convert_class_wrappers(text)
+ assert result == '
\n\nhttps://xkcd.com/327/\n\n
'
+
+
+def test_convert_class_wrappers_multiline():
+ text = ".animate[\n\n]"
+ result = convert_class_wrappers(text)
+ assert '
' in html_content or '
' in html_content
+
+
+def test_rewrite_relative_image_paths():
+ html = '

'
+ result = rewrite_image_paths(html)
+ assert 'src="/lecture_notes/images/photo.png"' in result
+
+
+def test_rewrite_leaves_absolute_paths():
+ html = '

'
+ result = rewrite_image_paths(html)
+ assert 'src="/examples/week_1/image.png"' in result
+
+
+def test_rewrite_leaves_external_urls():
+ html = '

'
+ result = rewrite_image_paths(html)
+ assert 'src="https://imgs.xkcd.com/comics/exploits.png"' in result
+
+
+def test_rewrite_does_not_touch_script_src():
+ html = ''
+ result = rewrite_image_paths(html)
+ assert result == html