↓ Skip to main content
AI & Data

Building about.samarthya.me, part 5: A small, fast site in Quarto

Building about.samarthya.me - This article is part of a series.
Part 5: This Article
A hub page is a signpost, and nobody wants to wait for a signpost to load. The goal for this site was simple: a handful of pages, built on my PC from data, light enough to open instantly on a phone, and with nothing on the server that can run.
The build: build_data.py writes fragments, Quarto renders pages with a theme and side menu, post_render.py swaps icon glyphs for inline SVG, giving 30 files and a 208 KB home page
From data to thirty static files

Parts 1 and 2 produced the numbers, 3 and 4 the front door. This part is the house itself.

Why Quarto, when the blog is Hugo
#

The blog stays on Hugo: hundreds of posts, fast builds, a theme I like. The hub is a different job. It has six pages, and almost everything on them is computed: tiles from the blog’s feeds, rankings from analytics, charts from numbers. That’s Python’s home ground.

Quarto is a publishing system that turns Markdown (with optional Python, R or Julia) into websites. It has Bootstrap themes, light and dark mode, and a side menu built in, and it can run a script of mine before and after every build. So Python does the data, Quarto does the pages, and neither has to pretend to be the other.

Words used in this post
#

TermMeaning here
Static sitePlain files (HTML, CSS, images); the server only hands them out, nothing runs
Pre-render / post-renderScripts Quarto runs before and after building the pages
FragmentA small generated piece of Markdown or HTML, pulled into a page
SCSSCSS with variables; Quarto themes are written in it
SVGPictures described as text (shapes and coordinates), so they stay sharp and small
Icon fontA font whose “letters” are icons; easy to use, heavy to download

The shape of the project
#

_quarto.yml          site settings: side menu, themes, the two scripts
index.qmd, stats.qmd, code.qmd, work.qmd, contact.qmd, 404.qmd
_generated/          fragments written by the pre-render script
styles/custom.scss   light theme tweaks, tiles, charts, menu
styles/dark.scss     dark theme values
icons/               Bootstrap Icons as SVG files
tools/build_data.py  pre-render: fetch, rank, draw, write fragments
tools/charts.py      bar charts, columns and the world map, as SVG
tools/post_render.py post-render: icon glyphs -> inline SVG
live.js              refreshes the home page's tiles in the browser (part 6)

The pages themselves are short. The home page is a heading, a sentence, and includes:

{{< include _generated/stats-strip.md >}}

::: {.panel-tabset}
## Recent
{{< include _generated/recent.md >}}
## Popular
{{< include _generated/popular.md >}}
:::

An include is “equivalent to copying and pasting the text from the included file into the main file”. The underscore in _generated/ matters: Quarto ignores files and folders starting with _ when it looks for pages to render.

The build, step by step
#

project:
  type: website
  pre-render: tools/build_data.py
  post-render: tools/post_render.py
  render: ["*.qmd"]
  1. Pre-render (Quarto’s project scripts): build_data.py downloads the blog’s latest.json and catalog.json, my GitHub profile, and reads the analytics files from part 1. It ranks, draws, and writes eighteen fragments into _generated/. Every download is cached, so a build still works offline with the last good copy.
  2. Render: Quarto turns each .qmd into HTML with the theme and side menu.
  3. Post-render: post_render.py edits the finished HTML (icons, below).

Three things bit me on the way:

  • Includes must exist before the pre-render runs. Quarto reads the pages, and their includes, before it calls my script. On a fresh checkout there were no fragments yet, so the build failed before the script that would have made them. The fix is unglamorous: commit _generated/ to git, so there is always a last copy.
  • render: ["*.qmd"]. Without it Quarto also rendered my PLAN.md notes as a public page. Rendering only .qmd files keeps notes, the README and the series outline out of the site.
  • Don’t build while previewing. quarto preview (live reload while editing) and quarto render share temporary folders. Running both at once fails with confusing “file not found” errors.

Navigation: a side menu, not a top bar#

Wide screens have room to the side, so the menu is a floating sidebar: five pages (Home, Stats, Code, Work, Find me) and a row of tool icons for the blog, GitHub, LinkedIn, X and RSS. On a phone Quarto folds it into “a title bar at the top which you can click or touch to reveal the navigation”, so there’s nothing extra to build for mobile.

Themes: light, dark, and an ordering trap
#

Quarto supports a light and a dark theme and, with respect-user-color-scheme: true, follows the visitor’s system setting:

format:
  html:
    theme:
      light: [journal, styles/custom.scss]
      dark: [darkly, styles/dark.scss, styles/custom.scss]
    respect-user-color-scheme: true

custom.scss holds everything shared: the blog’s orange as the accent (#c2410c, about 5.2:1 against white, enough for link text), system fonts instead of a web-font download, tile and chart styles. dark.scss holds the dark values: a lighter orange and its own chart colours.

The trap was the order. In dark mode my dark chart colours were ignored. The shared file declares them with Sass’s !default flag, which “assigns a value to a variable only if that variable isn’t defined”. So whichever file sets a variable first wins, and dark.scss has to come before custom.scss, not after it as you’d expect for “overrides”.

Icons without an icon font
#

Quarto draws its menu icons with the Bootstrap Icons font: one <i class="bi bi-github"> per icon, and a 180 KB font file to make them appear. For fifteen icons that’s a poor trade.

post_render.py fixes it after the build. It finds each <i class="bi bi-NAME"> in the HTML, replaces it with the same icon as inline SVG from icons/ (Bootstrap Icons, MIT licence, kept next to the icons), adds an icon in front of each menu entry, and, once no glyph is left anywhere, deletes the font and its stylesheet from the site:

GLYPH = re.compile(r'<i class="bi bi-([a-z0-9-]+)"[^>]*></i>')

for page in SITE.rglob("*.html"):
    html = page.read_text(encoding="utf-8")
    html = GLYPH.sub(lambda m: svg(m.group(1)) or m.group(0), html)  # unknown names stay as they were
    page.write_text(html, encoding="utf-8")

Inline SVG also takes the page’s text colour, so the icons follow light and dark mode for free.

Charts drawn at build time
#

The Stats page has eighteen charts and a world map, and loads no charting library. charts.py writes each chart as SVG text while the site is built:

  • Bars and columns with the value at the tip and labels in text colours, not the bar colour.
  • A tooltip per mark using SVG’s own <title> element: hover shows the value, no JavaScript.
  • A table view under each chart, in a <details> element (“Show as a table”), for screen readers and anyone who prefers numbers.
  • Colours as CSS variables, so the same SVG follows light and dark mode. The map’s colour steps were run through a contrast checker for both backgrounds, rather than picked by eye.

The map uses the Equal Earth projection (Šavrič, Patterson and Jenny, 2018). It’s equal-area, so countries near the poles aren’t inflated as they are on web maps, which matters when colour means “how many”. The outlines are Natural Earth’s 110m countries, in the public domain. Rounding every point to a whole pixel and dropping repeats keeps the map small enough to inline.

How small is small?
#

MeasureValue
Files in the site30
Size on disk1.8 MB
Home page with its CSS and scripts, compressedabout 208 KB
Largest single itemsBootstrap CSS, about 69 KB compressed, once per theme
Stats page HTML with all 18 SVG charts, compressedabout 43 KB
Icon font0 KB (was 180 KB)

The post images in the tiles come from the blog and load lazily, as they scroll into view. What’s left is mostly Bootstrap. It’s the next thing to trim, and the last: the rest is already text.

Takeaways
#

  • Use the tool that fits the job: Hugo for hundreds of posts, Quarto for a few computed pages.
  • Generate fragments in a pre-render script and pull them in with includes; commit the fragments so a fresh checkout can build.
  • Render only .qmd files, so notes never become pages.
  • With Sass !default, the file listed first wins.
  • A post-render step can replace a 180 KB icon font with a few hundred bytes of inline SVG.
  • SVG charts drawn at build time need no library, work in both themes, and can carry their own tooltips and table view.

References
#

Next: part 6, shipping it to a server I don’t trust, and keeping it fresh.

Author
Samarthya
Engineer, learner, tinkerer. This is my trophy shelf of everyday work and leisure.
Building about.samarthya.me - This article is part of a series.
Part 5: This Article

Related

Building about.samarthya.me, part 1: Google Analytics, from page tag to API

A static blog is a shop with no till: it serves pages, but it can’t count who came in. For that I lean on Google Analytics. This post is about understanding that tool properly, and teaching a small script to read its numbers, before I trust them on a public page. From Google Analytics to a static stats page, built on my own PC The series # After the move to Hugo the blog is fast and simple, but it has no front desk: nowhere that says here is what’s new, here is what people keep reading, here is where they come from. So I’m building a small hub, about.samarthya.me, and writing down each piece:

Building about.samarthya.me, part 2: Keeping the numbers honest

A scoreboard that counts the wind will tell you the wind is your biggest fan. Before the hub showed a single number about this blog’s readers, I checked what the raw numbers would have said. They would have been confidently wrong. Users are not readers: the gap, and the fix Part 1 set up a script that reads Google Analytics. This part is about what to do with what it reads. All numbers are from the blog’s analytics, since 2022, as of 7 November 2026.

Building about.samarthya.me, part 3: Cloudflare in front

The hub lives on an old server I no longer fully trust. So it gets a receptionist: every visitor talks to Cloudflare, and only Cloudflare talks to the server. This post is about setting up that front desk, record by record. One domain, four kinds of record, one front desk Part 1 and part 2 produced the data. Before the site can be published (part 6), it needs an address, a certificate and a sensible cache.