A revision site for the teenagers I teach Python to in person. After each lesson they come here to re-read what we covered, try the practice, and run Python in the browser without installing anything.
Live: https://bouwles.github.io/pauls-python-course/
Plain HTML, CSS and vanilla JavaScript. No React, no build step, no bundler, no
npm install, no backend, no tracking. Clone it and open index.html.
Home — every lesson, with how far through you are
Lesson page — editable examples that actually run, including input()
Python runs client side via Pyodide. input() opens an inline prompt in the
output area rather than a browser dialog, so a program can stop and wait for an
answer without freezing the page.
Practice — errors explained in English, and checks that never say "wrong"
Every traceback keeps its real line number and gets a plain-English line underneath it. Failed checks say what was expected and offer a hint.
Quizzes — recall, predict-the-output, and type-the-code
A loop that will not stop is survivable
Python runs in a Web Worker, so a runaway loop never freezes the page. Output is capped and counted, and Stop ends it with the code left in the editor.
On a phone, because that is what my students actually use
- Lessons rendered entirely from one data file, with a sticky sidebar of objectives and a contents list that follows you down the page.
- Runnable code blocks. Every example is editable and has Run and Copy. Pyodide loads lazily on the first Run and says so while it boots.
- An infinite loop cannot kill the page. Python runs in a Web Worker, so a
runaway
whileleaves the page fully usable. Stop ends it and the editor keeps the code. Nothing is ever killed automatically. input()support with an inline prompt, so programs that ask questions work like they do on a real machine.- Error translations. SyntaxError, NameError,
str + int, indentation at two levels and a dozen more get a sentence of plain English. So does the quiet case where a run prints nothing at all. Both tables live in one file. - Practice tasks with five kinds of gentle check, a hint button, and a worked answer that stays hidden until they have had a go themselves.
- Quizzes on their own page: multiple choice, predict-the-output, and type-the-line. No score — at the end it lists the sections behind whatever they got wrong first time, as links back into the lesson.
- Playground for messing about, with the code kept between visits.
- Progress in
localStorageonly. Nothing is sent anywhere, there are no accounts, no cookie banner and no analytics. - Light and dark themes, both designed properly, following the system setting with a manual toggle.
- Keyboard usable throughout, real focus rings, AA contrast in both themes.
Double-click index.html. That is genuinely it — Pyodide and CodeMirror come
from their CDNs and work fine from file://.
If you would rather serve it over http://, which is closer to how GitHub
Pages behaves:
python -m http.server 8000Then open http://localhost:8000.
Everything a lesson contains lives in data/lessons.js. That is the only
file you edit. Lessons 1 to 3 are written; lesson 4 is the next one.
- Open
data/lessons.js. - Copy the whole Lesson 3 object (from
{ id: 3,down to its closing},) and paste it below itself, inside the samewindow.LESSONS = [ ... ]array. - Change
idto4, give it a newslug,title,summary, and rewrite the contents. - Update lesson 3's
nextline, which is the teaser for what comes after it. - Scroll to
window.UPCOMINGat the bottom and delete the{ id: 4, ... }entry, so it stops showing as a locked card. - Save, refresh the browser. The lesson appears on the home page, at
lesson.html?id=4, and its quiz atquiz.html?id=4.
Nothing else needs touching. No HTML, no CSS, no JavaScript.
{
id: 4,
slug: "lists",
title: "Lists",
summary: "One line, shown on the lesson card.",
objectives: ["Shown in the sidebar", "Keep them short"],
sections: [ ... ], // see below
practice: [ ... ], // see below
quiz: [ ... ], // see below
cheatsheet: [ { code: "int(x)", meaning: "turn text into a number" } ],
next: "Teaser line for the lesson after this one."
}Each one becomes a numbered section with its own entry in the sidebar contents.
{ type: "text", heading: "...", body: "one string, or an array of paragraphs" }
{ type: "code", heading: "...", body: "...",
code: 'print("Hello")\n',
explain: "shown under the editor",
runnable: true } // false renders it read-only
{ type: "callout", heading: "...", body: "..." } // tinted, accent rule
{ type: "mistake", heading: "...", body: "...",
wrong: 'print(Hello)',
right: 'print("Hello")',
why: "the explanation" }Any section can also carry these, whatever its type:
aside: true // "Optional" tag, quieter styling, stepped back.
// For the bit you might skip in class.
table: { // a small table, for walking through something
head: ["Round", "total"], // step by step
rows: [["1st", "1"], ["2nd", "3"]],
code: false // default true: first column is set as code
}
after: "..." // paragraphs placed below the table,
// string or array
link: { href: "lesson.html?id=2#section-10",
text: "The version from lesson 2" } // a pointer somewhere elseSection anchors are #section-N, counting from 1 in the order they appear, so
lesson.html?id=2#section-10 is the tenth section of lesson 2.
{
id: "p1", // unique inside this lesson, used by progress
prompt: "Print your own name.",
starter: "# type your code here\n",
hint: "Shown when they press the hint button.",
solution: 'print("Paul")\n', // hidden until they have pressed Run once
check: { type: "output-not-empty" }
}| check type | what it does |
|---|---|
output-not-empty |
passes if the program printed anything |
output-contains |
value is a string or an array of strings, all must appear in the output (case-insensitive) |
output-matches |
value is a regex (or a regex string plus optional flags) tested against the output |
code-contains |
same as output-contains but tested against the code they wrote |
custom |
fn({ code, output }) returns true or false |
Any check can carry a message, the friendly sentence shown when it does not
pass. Give check an array if you need more than one, and all must pass:
check: [
{ type: "code-contains", value: "age =", message: "I could not find a variable called age." },
{ type: "output-not-empty" }
]These render on quiz.html?id=<lesson>, not on the lesson page. Three types.
choice is the default, so type can be left off. answer is the index of
the right option, counting from 0. review is the index of the section worth
re-reading if they get it wrong, and it drives the list shown at the end.
{
question: "What does # do?",
options: ["Runs the line twice", "Makes Python ignore the rest of the line"],
answer: 1,
explain: "Everything after # is skipped.",
review: 3 // -> section 4 in the lesson, 0-indexed
}Add optionsAreCode: true when the options are snippets rather than sentences,
and they will be set in the monospace face.
predict shows a code sample and asks what comes out. The options are output,
so they are always set as code and can contain newlines.
{
type: "predict",
question: "What comes out when this runs?",
code: '# print("one")\nprint("two")\n',
options: ["two", "one\ntwo", "one"],
answer: 0,
explain: "The first line is commented out.",
review: 3
}write asks them to type a line of Python. accept is a list of answers that
count as right; the first is shown if they get it wrong. Matching forgives
spacing and swaps single quotes for double, and nothing else, so Print is
still wrong.
{
type: "write",
question: "Write the line that shows the word Hello on the screen.",
placeholder: "one line of Python",
accept: ['print("Hello")'],
explain: "print, then brackets, then the text inside quotes.",
review: 1
}There is no score. When every question has been answered once, the quiz lists the sections behind the ones they got wrong on the first attempt, as links back into the lesson.
When a student's code errors, the red traceback gets a plain-English line
underneath it. That table is assets/js/errors.js. Add an entry:
{ test: /SomeError: the exact text/i, hint: "What it usually means." }First match wins, so put specific patterns above general ones. hint can also
be a function receiving the regex match, if you want to quote part of the error
back to them.
The same file holds PPC_SILENCE_HINTS, for the quiet failures where nothing
went wrong and nothing was printed either. Those confuse beginners more than a
red traceback does — a while whose condition is already false does nothing at
all and looks broken. These are matched against the code they wrote, not
against an error:
{ test: /^\s*while\s+.+:/m, hint: "Ran without printing anything. If a while..." }Every push to main publishes the site through GitHub Actions. There is no
build step — the workflow uploads the repo as-is.
git add -A
git commit -m "Add lesson 2"
git pushThen watch the run under the Actions tab. It takes about a minute.
If you fork this, go to Settings → Pages → Build and deployment and set Source to GitHub Actions. Nothing else to configure.
index.html home page: lesson list and progress
lesson.html renders one lesson from ?id=
quiz.html all quizzes, or one quiz with ?id=
playground.html blank editor
404.html
data/lessons.js ALL lesson content - the only file you edit to add lessons
assets/css/site.css the whole design, both themes
assets/js/store.js progress in localStorage, theme, small DOM helpers
assets/js/errors.js plain-English error translations
assets/js/runner.js Pyodide loading, input() handling, the editor widget
assets/js/lesson.js builds the lesson page
assets/js/quiz.js builds the quiz index and the quizzes
assets/js/home.js builds the home page
assets/js/playground.js
screenshots/ the images in this README
.github/workflows/deploy.yml
- Python is Pyodide, loaded from jsDelivr the first time anyone presses Run. It is a few megabytes, so the first run takes a few seconds and says so. After that it is instant for the rest of the visit.
- Python runs in a Web Worker, not on the page's own thread. That is the
only reason a student's infinite loop is survivable: the page stays
responsive and Stop can terminate the worker outright. The worker is built
from a
Blobrather than its own.jsfile, because a worker loaded from afile://path is blocked by the browser and ablob:one is not — which is what keeps "just open index.html" working. - The editor is CodeMirror 5 from cdnjs, which works as plain script tags. CodeMirror 6 would need a bundler, and the whole point here is that there is no build step. If either CDN is unreachable the editors fall back to plain textareas.
input()works because the code is rewritten before it runs:input(...)becomes an awaited call, and any function that ends up containing an await becomes anasync defalong with the calls to it. The tree is compiled directly rather than unparsed, so traceback line numbers still match what was typed. Beginner code is fine;input()inside a lambda or a comprehension is not. Because it is already asynchronous, it works inside the worker by message passing, with no need forSharedArrayBufferor the cross-origin isolation headers that GitHub Pages cannot send.- Stop terminates the worker, which is the only way to interrupt Python mid-loop. A replacement worker is started immediately in the background, so the run after a Stop is not slow. The editor is never touched.
- Runaway output is capped. A loop can print faster than any page can render, so the worker keeps the first 2000 lines of a run and from then on only counts, updating an "and N more lines, not shown" line. Output is flushed on elapsed time from inside the stdout hook rather than on a timer, because a tight Python loop never gives the worker's event loop a turn.
- Nothing is ever killed automatically. After five seconds a run just says it is still going and points at Stop. Some legitimate loops are slow.
- Progress is
localStorageunderppc:progress:v1. It never leaves the device, so the same student on a school computer and on their phone will see different progress. The reset button is in the footer.
Do what you like with the code. The lesson writing is mine.
MIT — see LICENSE.





