From c0a97923fe0a42671d7fa054c1119fdd5f8148e7 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 14:00:21 -0500 Subject: [PATCH 01/16] add design spec for static site generation migration Co-Authored-By: Claude Opus 4.6 (1M context) --- ...026-04-01-static-site-generation-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/specs/2026-04-01-static-site-generation-design.md 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. From cc206b3cd7e52fe441bd9c529209b965968e7512 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 14:13:35 -0500 Subject: [PATCH 02/16] add implementation plan for static site generation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-01-static-site-generation.md | 1339 +++++++++++++++++ 1 file changed, 1339 insertions(+) create mode 100644 docs/plans/2026-04-01-static-site-generation.md diff --git a/docs/plans/2026-04-01-static-site-generation.md b/docs/plans/2026-04-01-static-site-generation.md new file mode 100644 index 0000000..1924999 --- /dev/null +++ b/docs/plans/2026-04-01-static-site-generation.md @@ -0,0 +1,1339 @@ +# Static Site Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Python static site generator that converts remarkjs-flavored markdown into individual HTML slide pages with keyboard navigation. + +**Architecture:** A single `build.py` script that splits weekly markdown files into slides, strips remark syntax, renders markdown to HTML via python-markdown, and injects the result into Jinja2 templates. Output is a `_site/` directory of static HTML. + +**Tech Stack:** Python 3, Jinja2, python-markdown, Pygments + +**Spec:** `docs/specs/2026-04-01-static-site-generation-design.md` + +--- + +## File Structure + +``` +build.py # Main build script +requirements.txt # Python dependencies +preprocessor.py # Remark syntax stripping and slide splitting +templates/ + base.html # Shared HTML shell + slide.html # Single slide page + page.html # Generic content page (syllabus) + index.html # Course homepage + lecture_index.html # Week listing page +static/ + css/style.css # Site stylesheet (ported from remark CSS) + js/navigation.js # Keyboard slide navigation +tests/ + test_preprocessor.py # Tests for remark syntax stripping + test_build.py # Integration tests for build output +``` + +--- + +### Task 1: Project Scaffolding + +**Files:** +- Create: `requirements.txt` +- Create: `tests/__init__.py` +- Create: `tests/test_preprocessor.py` (empty initially) + +- [ ] **Step 1: Create `requirements.txt`** + +``` +Jinja2>=3.1 +Markdown>=3.5 +Pygments>=2.17 +``` + +- [ ] **Step 2: Create empty test package** + +```bash +mkdir -p tests +touch tests/__init__.py +``` + +- [ ] **Step 3: Install dependencies** + +Run: `pip install -r requirements.txt` +Expected: Successfully installs Jinja2, Markdown, Pygments and their dependencies. + +- [ ] **Step 4: Commit** + +```bash +git add requirements.txt tests/__init__.py +git commit -m "add project scaffolding for static site generator" +``` + +--- + +### Task 2: Slide Splitting + +**Files:** +- Create: `preprocessor.py` +- Create: `tests/test_preprocessor.py` + +- [ ] **Step 1: Write failing test for basic slide splitting** + +`tests/test_preprocessor.py`: +```python +from preprocessor import split_slides + + +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] # The --- inside the code block stays + + +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" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_preprocessor.py -v` +Expected: FAIL — `ImportError: cannot import name 'split_slides'` + +- [ ] **Step 3: Implement `split_slides`** + +`preprocessor.py`: +```python +import re + + +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() + + # Track fenced code blocks + if stripped.startswith("```"): + in_code_block = not in_code_block + + # A line containing only --- outside a code block is a slide boundary + if stripped == "---" and not in_code_block: + slides.append("\n".join(current_slide)) + current_slide = [] + else: + current_slide.append(line) + + # Don't forget the last slide + if current_slide: + slides.append("\n".join(current_slide)) + + # Strip leading/trailing whitespace from each slide, drop empty slides + return [s.strip() for s in slides if s.strip()] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_preprocessor.py -v` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add preprocessor.py tests/test_preprocessor.py +git commit -m "add slide splitting with fenced code block awareness" +``` + +--- + +### Task 3: Strip Class Directives + +**Files:** +- Modify: `preprocessor.py` +- Modify: `tests/test_preprocessor.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_preprocessor.py`: +```python +from preprocessor import strip_class_directive + + +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"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_preprocessor.py::test_strip_class_directive_basic -v` +Expected: FAIL — `ImportError` + +- [ ] **Step 3: Implement `strip_class_directive`** + +Add to `preprocessor.py`: +```python +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, [] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_preprocessor.py -v -k "class_directive"` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add preprocessor.py tests/test_preprocessor.py +git commit -m "add class directive stripping from slides" +``` + +--- + +### Task 4: Strip Presenter Notes and Incremental Reveals + +**Files:** +- Modify: `preprocessor.py` +- Modify: `tests/test_preprocessor.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_preprocessor.py`: +```python +from preprocessor import strip_presenter_notes, strip_incremental_reveals + + +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 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_preprocessor.py -v -k "presenter or incremental"` +Expected: FAIL — `ImportError` + +- [ ] **Step 3: Implement both functions** + +Add to `preprocessor.py`: +```python +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 # Drop everything from here to end of slide + + 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 # Skip this line + + result.append(line) + + return "\n".join(result) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_preprocessor.py -v -k "presenter or incremental"` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add preprocessor.py tests/test_preprocessor.py +git commit -m "add presenter notes and incremental reveal stripping" +``` + +--- + +### Task 5: Convert `.classname[content]` Wrappers + +**Files:** +- Modify: `preprocessor.py` +- Modify: `tests/test_preprocessor.py` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/test_preprocessor.py`: +```python +from preprocessor import convert_class_wrappers + + +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[![Image](foo.png)]" + result = convert_class_wrappers(text) + assert result == '
\n\n![Image](foo.png)\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![Phil](images/phil.jpeg)\n]" + result = convert_class_wrappers(text) + assert '
' in result + assert "![Phil](images/phil.jpeg)" in result + + +def test_convert_class_wrappers_nested_brackets(): + text = ".big[text with [link](url)]" + result = convert_class_wrappers(text) + assert "[link](url)" in result + + +def test_no_class_wrappers(): + text = "Just normal markdown" + result = convert_class_wrappers(text) + assert result == "Just normal markdown" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_preprocessor.py -v -k "class_wrappers"` +Expected: FAIL — `ImportError` + +- [ ] **Step 3: Implement `convert_class_wrappers`** + +Add to `preprocessor.py`: +```python +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) + # Collect lines until we find a closing ] + 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.""" + # Pattern: .classname[ at start of line (block) or inline + 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) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_preprocessor.py -v -k "class_wrappers"` +Expected: All 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add preprocessor.py tests/test_preprocessor.py +git commit -m "add .classname[content] wrapper conversion" +``` + +--- + +### Task 6: Full Preprocessing Pipeline + +**Files:** +- Modify: `preprocessor.py` +- Modify: `tests/test_preprocessor.py` + +- [ ] **Step 1: Write failing test for the combined pipeline** + +Append to `tests/test_preprocessor.py`: +```python +from preprocessor import process_slide + + +def test_process_slide_full(): + slide = """class: center, middle +# Title +.big[**Important**] + +-- + +More content + +??? + +Speaker notes here""" + html_content, classes = process_slide(slide) + assert classes == ["center", "middle"] + assert "Speaker notes" not in html_content + assert "Important" in html_content + assert "More content" in html_content + assert "???" not in html_content + assert '' in html_content or '
' 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 = 'Photo' + result = rewrite_image_paths(html) + assert 'src="/lecture_notes/images/photo.png"' in result + + +def test_rewrite_leaves_absolute_paths(): + html = 'Photo' + result = rewrite_image_paths(html) + assert 'src="/examples/week_1/image.png"' in result + + +def test_rewrite_leaves_external_urls(): + html = 'Comic' + 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 %} +
+

Lecture Notes

+

+ Lecture notes are written in + Markdown + and can also be found + in the Course Materials repo on GitHub. +

+ +
+{% 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** From c454919faf32913997fdf04daf920d84d093a9b6 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 14:29:20 -0500 Subject: [PATCH 03/16] add .worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 36ed094..bcbbe5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store apikey.js *.pyc -*/__pycache__/* \ No newline at end of file +*/__pycache__/* +.worktrees/ \ No newline at end of file From 1aa1bc7af70169f80c47f8e02520c07fdb35b364 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 14:32:24 -0500 Subject: [PATCH 04/16] add project scaffolding for static site generator --- requirements.txt | 3 +++ tests/__init__.py | 0 2 files changed, 3 insertions(+) create mode 100644 requirements.txt create mode 100644 tests/__init__.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c89c25d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Jinja2>=3.1 +Markdown>=3.5 +Pygments>=2.17 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From a218b1eabd71543e145cc43c9bc4fad37bf5ac60 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 14:33:31 -0500 Subject: [PATCH 05/16] add slide splitting with fenced code block awareness --- preprocessor.py | 76 ++++++++++++++++++++++++++++++++++++++ tests/test_preprocessor.py | 23 ++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 preprocessor.py create mode 100644 tests/test_preprocessor.py diff --git a/preprocessor.py b/preprocessor.py new file mode 100644 index 0000000..289c365 --- /dev/null +++ b/preprocessor.py @@ -0,0 +1,76 @@ +import re + + +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) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py new file mode 100644 index 0000000..cb2f867 --- /dev/null +++ b/tests/test_preprocessor.py @@ -0,0 +1,23 @@ +from preprocessor import split_slides + + +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" From fea1c26d4c6304e51b05958bcdca1b241918cff7 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 16:54:10 -0500 Subject: [PATCH 06/16] add strip_class_directive with tests --- tests/test_preprocessor.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index cb2f867..cab112b 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -1,4 +1,4 @@ -from preprocessor import split_slides +from preprocessor import split_slides, strip_class_directive def test_split_slides_basic(): @@ -21,3 +21,24 @@ def test_split_slides_trims_whitespace(): 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"] From c4fe9822f79d5a4218b4df594c4adc50f4b9236d Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 16:56:49 -0500 Subject: [PATCH 07/16] add strip_presenter_notes and strip_incremental_reveals with tests --- tests/test_preprocessor.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index cab112b..b27ddf1 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -1,4 +1,4 @@ -from preprocessor import split_slides, strip_class_directive +from preprocessor import split_slides, strip_class_directive, strip_presenter_notes, strip_incremental_reveals def test_split_slides_basic(): @@ -42,3 +42,38 @@ def test_strip_class_directive_single(): 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 From c82882da849bd09def2f9c25e8ed899d6d811792 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 17:08:49 -0500 Subject: [PATCH 08/16] add .classname[content] wrapper conversion Co-Authored-By: Claude Opus 4.6 (1M context) --- preprocessor.py | 65 ++++++++++++++++++++++++++++++++++++++ tests/test_preprocessor.py | 46 ++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/preprocessor.py b/preprocessor.py index 289c365..e04e7b9 100644 --- a/preprocessor.py +++ b/preprocessor.py @@ -74,3 +74,68 @@ def strip_incremental_reveals(slide: str) -> str: 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) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index b27ddf1..5b2cb37 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -1,4 +1,4 @@ -from preprocessor import split_slides, strip_class_directive, strip_presenter_notes, strip_incremental_reveals +from preprocessor import split_slides, strip_class_directive, strip_presenter_notes, strip_incremental_reveals, convert_class_wrappers def test_split_slides_basic(): @@ -77,3 +77,47 @@ 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[![Image](foo.png)]" + result = convert_class_wrappers(text) + assert result == '
\n\n![Image](foo.png)\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![Phil](images/phil.jpeg)\n]" + result = convert_class_wrappers(text) + assert '
Date: Wed, 1 Apr 2026 17:16:44 -0500 Subject: [PATCH 09/16] add full slide preprocessing pipeline Co-Authored-By: Claude Sonnet 4.6 --- preprocessor.py | 33 +++++++++++++++++++++++++++++++++ tests/test_preprocessor.py | 23 ++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/preprocessor.py b/preprocessor.py index e04e7b9..9424d80 100644 --- a/preprocessor.py +++ b/preprocessor.py @@ -1,5 +1,7 @@ import re +import markdown + def split_slides(markdown: str) -> list[str]: """Split markdown on --- slide boundaries, ignoring --- inside fenced code blocks.""" @@ -139,3 +141,34 @@ def replace_match(match): # 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 diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index 5b2cb37..99271d0 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -1,4 +1,4 @@ -from preprocessor import split_slides, strip_class_directive, strip_presenter_notes, strip_incremental_reveals, convert_class_wrappers +from preprocessor import split_slides, strip_class_directive, strip_presenter_notes, strip_incremental_reveals, convert_class_wrappers, process_slide def test_split_slides_basic(): @@ -121,3 +121,24 @@ def test_no_class_wrappers(): text = "Just normal markdown" result = convert_class_wrappers(text) assert result == "Just normal markdown" + + +def test_process_slide_full(): + slide = """class: center, middle +# Title +.big[**Important**] + +-- + +More content + +??? + +Speaker notes here""" + html_content, classes = process_slide(slide) + assert classes == ["center", "middle"] + assert "Speaker notes" not in html_content + assert "Important" in html_content + assert "More content" in html_content + assert "???" not in html_content + assert '' in html_content or '
' in html_content From 7a5d762ac9dd64167e2298add8234862121e69ce Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 17:17:12 -0500 Subject: [PATCH 10/16] add image path rewriting for slide output Co-Authored-By: Claude Sonnet 4.6 --- preprocessor.py | 14 ++++++++++++++ tests/test_preprocessor.py | 26 +++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/preprocessor.py b/preprocessor.py index 9424d80..3e80f16 100644 --- a/preprocessor.py +++ b/preprocessor.py @@ -172,3 +172,17 @@ def process_slide(slide_md: str) -> tuple[str, list[str]]: html = md.convert(content) return html, classes + + +def rewrite_image_paths(html: str) -> str: + """Rewrite relative image src paths to absolute /lecture_notes/ paths. + Only affects tags, not ' + result = rewrite_image_paths(html) + assert result == html From 5f65e15b746de7866eef9d0a7275c2fb9673d7a9 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 17:18:40 -0500 Subject: [PATCH 11/16] add Jinja2 templates for slides, pages, and indexes Co-Authored-By: Claude Sonnet 4.6 --- templates/base.html | 23 ++++++++++++++++++++++ templates/index.html | 21 ++++++++++++++++++++ templates/lecture_index.html | 20 +++++++++++++++++++ templates/page.html | 9 +++++++++ templates/slide.html | 38 ++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/lecture_index.html create mode 100644 templates/page.html create mode 100644 templates/slide.html diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5489469 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}Web Development{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9456470 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,21 @@ +{% 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 %} diff --git a/templates/lecture_index.html b/templates/lecture_index.html new file mode 100644 index 0000000..435a71a --- /dev/null +++ b/templates/lecture_index.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Lecture Notes | Web Development{% endblock %} + +{% block content %} +
+

Lecture Notes

+

+ Lecture notes are written in + Markdown + and can also be found + in the Course Materials repo on GitHub. +

+ +
+{% endblock %} diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..56d3742 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}{{ title }} | Web Development{% endblock %} + +{% block content %} +
+ {{ content }} +
+{% endblock %} diff --git a/templates/slide.html b/templates/slide.html new file mode 100644 index 0000000..9cdecd0 --- /dev/null +++ b/templates/slide.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}Week {{ week }} — Slide {{ slide_num }} | Web Development{% endblock %} + +{% block content %} +
+ +
+ {% if prev_url %} + + {% else %} + + {% endif %} + + Week {{ week }} — {{ slide_num }} / {{ total_slides }} + + {% if next_url %} + + {% else %} + + {% endif %} +
+ +
+ {{ content }} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} From c8ad3de394b3358b9d0f2b5862448e7984a3fc12 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 17:18:44 -0500 Subject: [PATCH 12/16] add keyboard navigation for slides --- static/js/navigation.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 static/js/navigation.js diff --git a/static/js/navigation.js b/static/js/navigation.js new file mode 100644 index 0000000..8d4ef46 --- /dev/null +++ b/static/js/navigation.js @@ -0,0 +1,28 @@ +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; + } + }); +}); From 9e0c25d724c2df85545d9c9930a92de1fcb6271c Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Wed, 1 Apr 2026 17:18:54 -0500 Subject: [PATCH 13/16] add site stylesheet ported from remark CSS --- static/css/style.css | 183 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 static/css/style.css diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..e8e3e24 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,183 @@ +/* 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; +} From be8ad57adb6e17c49205f56615400f506d403e17 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Thu, 2 Apr 2026 09:41:20 -0500 Subject: [PATCH 14/16] add build script for static site generation Integrates preprocessor, Jinja2 templates, CSS, and JS into a complete build pipeline that reads markdown lecture notes and produces a static site in _site/. Includes integration tests and adds _site/ to .gitignore. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +- build.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_build.py | 39 +++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 build.py create mode 100644 tests/test_build.py diff --git a/.gitignore b/.gitignore index bcbbe5e..83ced5b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ apikey.js *.pyc */__pycache__/* -.worktrees/ \ No newline at end of file +.worktrees/ +_site/ \ No newline at end of file diff --git a/build.py b/build.py new file mode 100644 index 0000000..176e44c --- /dev/null +++ b/build.py @@ -0,0 +1,100 @@ +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 — autoescape=False since templates use {{ content }} + # with pre-rendered HTML and this is a static site generator, not a web app + env = Environment(loader=FileSystemLoader(root / "templates"), autoescape=False) + + # 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() diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..3678ee2 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,39 @@ +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() From 677d47d9517d7ad97fa79636b8b7ba9bda00bd54 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Thu, 2 Apr 2026 09:42:30 -0500 Subject: [PATCH 15/16] add GitHub Actions workflow for Pages deployment --- .github/workflows/build-and-deploy.yml | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/build-and-deploy.yml diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml new file mode 100644 index 0000000..4e7d0e9 --- /dev/null +++ b/.github/workflows/build-and-deploy.yml @@ -0,0 +1,39 @@ +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 From 825d27e3002822f0607b23ea042abada8b5a6036 Mon Sep 17 00:00:00 2001 From: Trevor Austin Date: Fri, 3 Apr 2026 12:20:06 -0500 Subject: [PATCH 16/16] add base_url support for GitHub Pages project sites All internal links were hardcoded to the domain root (/css/..., /lecture_notes/..., etc.), which breaks on project sites served at a subpath like /course_materials/. Threads a --base-url CLI arg through build.py, Jinja2 globals, and image path rewriting. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build-and-deploy.yml | 2 +- build.py | 28 ++++++++++++++++++-------- preprocessor.py | 4 ++-- templates/base.html | 10 ++++----- templates/index.html | 6 +++--- templates/lecture_index.html | 2 +- templates/slide.html | 2 +- tests/test_build.py | 23 +++++++++++++++++++-- 8 files changed, 54 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 4e7d0e9..97db00d 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -23,7 +23,7 @@ jobs: with: python-version: "3.12" - run: pip install -r requirements.txt - - run: python build.py + - run: python build.py --base-url "/${{ github.event.repository.name }}" - uses: actions/upload-pages-artifact@v3 with: path: _site diff --git a/build.py b/build.py index 176e44c..00dc940 100644 --- a/build.py +++ b/build.py @@ -1,5 +1,5 @@ +import argparse import glob -import os import re import shutil from pathlib import Path @@ -10,8 +10,12 @@ from preprocessor import split_slides, process_slide, rewrite_image_paths -def build_site(output_dir: str = "_site"): - """Build the entire static site.""" +def build_site(output_dir: str = "_site", base_url: str = ""): + """Build the entire static site. + + base_url: path prefix for project sites (e.g., "/course_materials"). + Empty string for sites served at the domain root. + """ root = Path(__file__).parent out = Path(output_dir) @@ -23,6 +27,7 @@ def build_site(output_dir: str = "_site"): # Set up Jinja2 — autoescape=False since templates use {{ content }} # with pre-rendered HTML and this is a static site generator, not a web app env = Environment(loader=FileSystemLoader(root / "templates"), autoescape=False) + env.globals["base_url"] = base_url # Copy static assets shutil.copytree(root / "static" / "css", out / "css") @@ -48,11 +53,11 @@ def build_site(output_dir: str = "_site"): for i, slide_md in enumerate(slide_strings, 1): html_content, classes = process_slide(slide_md) - html_content = rewrite_image_paths(html_content) + html_content = rewrite_image_paths(html_content, base_url) - 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/" + prev_url = f"{base_url}/lecture_notes/week_{week_num}/{i - 1}/" if i > 1 else "" + next_url = f"{base_url}/lecture_notes/week_{week_num}/{i + 1}/" if i < total_slides else "" + first_url = f"{base_url}/lecture_notes/week_{week_num}/1/" slide_html = env.get_template("slide.html").render( content=html_content, @@ -97,4 +102,11 @@ def build_site(output_dir: str = "_site"): if __name__ == "__main__": - build_site() + parser = argparse.ArgumentParser(description="Build the static site.") + parser.add_argument( + "--base-url", + default="", + help="URL path prefix for project sites (e.g., /course_materials)", + ) + args = parser.parse_args() + build_site(base_url=args.base_url.rstrip("/")) diff --git a/preprocessor.py b/preprocessor.py index 3e80f16..1f43e6a 100644 --- a/preprocessor.py +++ b/preprocessor.py @@ -174,7 +174,7 @@ def process_slide(slide_md: str) -> tuple[str, list[str]]: return html, classes -def rewrite_image_paths(html: str) -> str: +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/test_build.py b/tests/test_build.py index 3678ee2..df0f595 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,5 +1,3 @@ -import os -import shutil from pathlib import Path from build import build_site @@ -37,3 +35,24 @@ def test_build_no_week_8_old(tmp_path, monkeypatch): 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