● tutorial 03 · you've built a page before

Build the machine, not the pages.

Most sites are written page by page. Then you change one thing in the nav and edit it eleven times. This tutorial is about writing the thing that writes the pages, so you only ever change something once.

You'll build a small generator. It's a script that takes a description of your site and produces every page from it. Same pattern behind most professional sites, minus the framework.

💡 What you need: comfort with HTML and CSS from Tutorial 01, plus Python installed. If you can read a for loop, you're ready.

The Side Quest badge
Nine colourways of this exist. Nobody drew them nine times.
1

The Copy-Paste Trap

why hand-written sites stop growing

You build page one. It's good. You copy it to make page two, change the heading, copy again for page three. By page seven you have seven copies of the same header, the same footer, the same navigation.

Then you add a new section to the nav. Now you edit seven files. You miss one. Two months later someone finds a page with the old nav on it and you have no idea how long it's been broken.

⚠️ This isn't a beginner mistake. It's what happens to every hand-built site past a certain size. The fix isn't discipline. It's not having seven copies in the first place.

The shift

Stop thinking of your site as a set of pages you write. Start thinking of it as a description plus a machine. The description says what pages exist and what's different about them. The machine turns that into HTML.

1
A list of
your pages
→
2
One page
template
→
3
A script that
combines them
→
4
Every page,
always in sync

Change the template once, every page updates. Add a page to the list, it appears in every nav automatically. That's the whole idea. The next six lessons build it.

💡 This is what frameworks do under the hood. You're building a very small one, which means you'll understand every line of it. It also won't break because of an update you didn't ask for.

2

Decide Once

tokens, and why sections can have their own

Tutorial 01 covered design tokens: define a colour once at the top, reference it everywhere. Here's the version that scales further.

css/style.css
:root {
  --paper: #FBF3E4;
  --ink:   #1C2B3A;
  --accent: #E15A2C;   /* default */
}

/* each section overrides just the accent */
body.t-comic   { --accent: #1C2B3A; }
body.t-photos  { --accent: #FF2E92; }
body.t-thought { --accent: #2C6E9E; }

/* everything downstream just uses var(--accent) */
.eyebrow      { color: var(--accent); }
nav a.on      { background: var(--accent); }
.btn          { box-shadow: 4px 4px 0 var(--accent); }

One class on the <body> tag changes the entire page's accent colour. Not just one element. The eyebrow pill, the active nav item, the button shadow, the footer tagline, all of it, because they all point at the same variable.

💰 The rule underneath: never let the same decision live in two places. If changing your brand colour means editing more than one line, your setup is wrong.

Exercise 01

Theme by class

Take a page you've built. Add --accent to :root, point at least four rules at it, then add two body classes that override it. Swap the class and reload.

What good looks like

If you have to touch more than the two override blocks to change a section's colour, something downstream is still hardcoding a hex value. Find it and point it at the variable.

3

One Template

the shell function

Every page on a site shares most of its HTML: the doctype, the head, the header, the nav, the footer. Only a middle chunk differs. So write that shared part once, as a function with a hole in it.

build.py
def shell(title, current, body, accent_class):
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{title}</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body class="{accent_class}">

  <header>
    <nav>{nav(current)}</nav>
  </header>

{body}

  <footer>
    <p>© Your Name</p>
  </footer>
</body>
</html>"""

That f"""...""" is an f-string. It's a Python string where anything in curly braces gets swapped for a real value. {title} becomes the actual title. The whole page is one string with holes in it.

⚠️ One gotcha that will bite you: f-strings treat { as special. If your template contains JavaScript or CSS with real curly braces, Python will try to interpret them and throw a SyntaxError. Either double them ({{) or keep that block out of the f-string entirely.

The nav writes itself too

build.py
def nav(current):
    out = []
    for slug, label in PAGES:
        # mark the page we're currently on
        cls = ' class="on"' if slug == current else ""
        out.append(f'<a href="/{slug}/"{cls}>{label}</a>')
    return "\\n".join(out)

Add a page to PAGES and it appears in the nav of every single page. Delete one and it vanishes everywhere. You never edit a nav by hand again.

4

Describe Your Pages

the data table

Now the description. This is the part you'll actually edit day to day. One table that says what exists.

build.py
# slug, label, accent class, page description
PAGES = [
  ("work",   "Work",   "t-classic", "Things I made on purpose."),
  ("notes",  "Notes",  "t-deep",    "Thinking out loud."),
  ("photos", "Photos", "t-rave",    "The ordinary, noticed."),
  ("shop",   "Shop",   "t-classic", "Objects, occasionally."),
]

Four lines describe a four-page site. Everything else is derived from it: the nav order, the URLs, the folder structure, which colour each page wears, what the heading says.

💡 Notice shop and work share t-classic. Sections can reuse a theme and the table doesn't care. That's the advantage of describing intent rather than hand-coding results.

Why a tuple and not something fancier

You could use a dictionary, a class, a JSON file. For fewer than about twenty pages, a list of tuples is easier to read at a glance and impossible to get subtly wrong. Reach for structure when the pain arrives, not before.

5

The Loop

where it all comes together

Ten lines. This is the entire generator.

build.py
import os

ROOT = "site"

for slug, label, accent, desc in PAGES:
    body = f"""
  <div class="wrap">
    <span class="eyebrow">● the {label.lower()}</span>
    <h1>{label}</h1>
    <p>{desc}</p>
  </div>"""

    html = shell(f"{label} — My Site", slug, body, accent)

    os.makedirs(f"{ROOT}/{slug}", exist_ok=True)
    open(f"{ROOT}/{slug}/index.html", "w").write(html)

print(f"built {len(PAGES)} pages")

Run python3 build.py. Four folders appear, each with an index.html, each with a correct nav, each wearing its own accent colour. Add a fifth line to PAGES, run it again, five pages.

💰 The moment this clicks is when you change the footer once and watch every page update. That feeling is why the pattern exists.

Folders make clean URLs

Writing to site/work/index.html instead of site/work.html gives you yoursite.com/work/ rather than yoursite.com/work.html. Servers look for index.html inside a folder automatically. Same effort, nicer addresses.

Exercise 02

Generate your own site

Write build.py with the four pieces: PAGES, nav(), shell(), and the loop. Generate at least four pages. Then add a fifth by editing one line.

If it crashes

SyntaxError near a brace: f-string collision. Your template has literal { in CSS or JS. Double it or move that block out.

FileNotFoundError: the parent folder doesn't exist. That's what exist_ok=True is for, so check you kept it.

Pages generate but look unstyled: your stylesheet path is relative. Use /css/style.css with the leading slash, and test through a local server, not by double-clicking the file.

6

Assets That Stay Sharp

the bug nobody warns you about

SVG is supposed to be infinitely scalable. Mostly it is. But some SVGs use blur-based filters for glows, merged shapes and soft shadows. When the browser shrinks one of those to thumbnail size, the effects don't scale down cleanly. They turn to mush.

You'll notice it as: my logo looks perfect full size and blurry in the footer.

⚠️ The fix is not "add more resolution." Vector doesn't work that way. The fix is to pre-render a PNG at the size you'll actually display, once, at build time.

thumbs.js — run once, not on every page load
const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const b = await chromium.launch();
  const [w, h] = [300, 300];

  let svg = fs.readFileSync('logo.svg', 'utf8');
  // force the root tag to the size we want, or the
  // original width/height wins and we get a crop
  svg = svg.replace(/<svg\s/, `<svg width="${w}" height="${h}" `);

  const p = await b.newPage({
    viewport: { width: w, height: h },
    deviceScaleFactor: 2          // retina
  });
  await p.setContent(`<body style="margin:0">${svg}</body>`);
  await p.screenshot({ path: 'thumb.png', omitBackground: true });
  await b.close();
})();

That one replace line is the part people miss. If your SVG has hardcoded width and height attributes, setting a smaller browser window doesn't scale the graphic down. It just shows you a small window onto a full-size render. You get a corner, not a thumbnail.

💡 Rule of thumb: SVG for anything that scales or gets edited. Pre-rendered PNG for anything that appears at a fixed small size, like favicons, nav marks, thumbnails and social share images.

💰 The wider principle: do expensive work once, at build time, not every time someone loads the page. That's what a build step is for.

7

Rebuild Without Fear

the discipline that keeps it safe

Generators have one real danger: the script overwrites its output folder. If you ever hand-edit a generated file, the next build silently deletes your work.

⚠️ Never edit a generated file. If you find yourself opening site/work/index.html to fix something, stop. The fix belongs in build.py, or the file shouldn't be generated at all.

Keep hand-made things out of the blast radius

Some files aren't generated. A hand-written essay, a server function, a folder of photos. Keep those in directories the generator never touches, and make that explicit in your script.

build.py
GENERATED = ["work", "notes", "photos", "shop"]
# everything else in site/ is hand-made and left alone:
#   site/css/        stylesheet
#   site/assets/     images
#   site/functions/  server code
#   site/essays/     written by hand, on purpose

The build ritual

  • Edit build.py or your content, never the output
  • Run the generator
  • Serve locally and look at it with python3 -m http.server 8000
  • Commit both the source and the output
  • Deploy

Committing the output as well as the source feels redundant. It isn't. It means you can see in your version history exactly what changed on the live site, and roll back a bad build without re-running anything.

💰 A generator you're afraid to run is worse than no generator. The whole value is being able to rebuild everything at any time and get an identical result. Guard that.

★

Your Turn

practice challenges
Level 1

Add a 404

Generate a 404.html at the root using the same shell(). It's a page with no nav highlight, so figure out what to pass as current.

Level 2

Two templates

Add a second body shape for a different kind of page, say a list of entries versus a single entry. Choose between them inside the loop.

Level 3

Content from files

Move page text out of PAGES into .md files the script reads. Now you write content without touching code.

?

Glossary

words you'll meet again
Generator — a script that writes your site's files instead of you writing them by hand.
Build step — work done once before deploying, rather than every time a visitor loads the page.
Template — the shared shape of a page, with holes where the specifics go.
f-string — a Python string where {expressions} are replaced by their values.
Slug — the short lowercase word that becomes a page's folder name and URL.
Design token — a value like a colour, defined once and referenced by name everywhere.
Pre-rendering — producing a fixed image at build time so the browser doesn't have to compute it.
Idempotent — safe to run repeatedly with the same result. Your generator should be.