About this course
<p>I've been following a <a href="https://github.com/clayh53/tufte-jekyll">tufte-jekyll</a> styled blog for a couple of years and that led me to discover Edward Tufte's <a href="https://www.edwardtufte.com/books/">book</a> layout.</p>
<p><a href="https://en.wikipedia.org/wiki/Edward_Tufte">Edward Tufte</a> is renowned for his work on data visualization and information design, and he's a fierce advocate of high data density and for the removal of "<a href="https://en.wikipedia.org/wiki/Chartjunk">chartjunk</a>".</p>
<p>This is what <a href="https://edwardtufte.github.io/tufte-css/">tufte-css</a> (and its many ports, including this one) brings to the web: generous whitespace, a serif reading column, and precious <em>sidenotes</em> for supplementary information (instead of disruptive modals).</p>
<p>I liked almost everything about <em>tufe-jekyll</em> blogs except the parts that had nothing to do with writing: a <a href="https://jekyllrb.com/">Jekyll</a> powered <a href="https://en.wikipedia.org/wiki/Ruby_(programming_language)">Ruby</a> version I only ever touched for this one project.</p>
<p>So I rewrote the whole theme in Python. Not because Jekyll is bad. It isn't. But because I wanted a toolchain I'm comfortable with. I was also curious whether I actually understood and could assimilate how a <a href="https://www.netlify.com/blog/2020/04/14/what-is-a-static-site-generator-and-3-ways-to-find-the-best-one/">static site generator</a> works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/72b186a8-6781-4101-a58c-09331f54fba0.gif" alt="Animated screenshot that displays an accessible Tufte layout template." width="600" height="400" loading="lazy">
<p><a href="https://github.com/hyperphantasia/tufte-python">tufte-python</a> is that port and this write-up acts as a guide: what actually has to happen when you move a Liquid-based Jekyll theme to a Python one, and <em>the specific places</em> I got it wrong before I got it right.</p>
<p>None of this is Jekyll-specific advice. The same pattern applies whether your target is <a href="https://github.com/gohugoio/hugo">Hugo</a> (GoLang), <a href="https://github.com/11ty/buildawesome">Eleventy</a> (JavaScript), or something else.</p>
<p>Here, you'll tinker on very focused technical points but also discover a way to break things down. If you're porting a different theme, or porting to a different language entirely, remember: the syntax changes but <em>the shape</em> of the challenge is the same.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-but-wait-why-a-static-blog">But Wait, Why a Static Blog?</a></p>
</li>
<li><p><a href="#heading-why-port-a-theme-instead-of-just-using-it-as-is">Why Port a Theme Instead of Just Using It As-Is?</a></p>
</li>
<li><p><a href="#heading-what-youll-need">What You'll Need</a></p>
</li>
<li><p><a href="#heading-see-the-destination-first-get-the-finished-port-running">See the Destination First: Get the Finished Port Running</a></p>
<ul>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-write-your-first-post">Write your First Post</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-from-tufte-jekyll-to-tufte-python-step-by-step">From tufte-jekyll to tufte-python, Step by Step</a></p>
</li>
<li><p><a href="#heading-step-1-inventory-the-source-themes-moving-parts">Step 1: Inventory the Source Theme's Moving Parts</a></p>
</li>
<li><p><a href="#heading-step-2-collapse-scattered-config-into-one-file">Step 2: Collapse Scattered Config Into One File</a></p>
</li>
<li><p><a href="#heading-step-3-rebuild-custom-liquid-tags-as-text-shortcodes">Step 3: Rebuild Custom Liquid Tags as Text Shortcodes</a></p>
<ul>
<li><a href="#heading-how-jekyll-does-it">How Jekyll does it</a></li>
</ul>
</li>
<li><p><a href="#heading-why-you-cant-just-port-this-11-into-jinja2">Why you can't just port this 1:1 into Jinja2</a></p>
<ul>
<li><p><a href="#heading-what-actually-works">What actually works</a></p>
</li>
<li><p><a href="#heading-the-rendered-features">The rendered features</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-4-replace-compiled-sass-with-swappable-plain-css">Step 4: Replace Compiled Sass With Swappable Plain CSS</a></p>
</li>
<li><p><a href="#heading-step-5-swap-filesystem-watching-for-an-explicit-build-cache">Step 5: Swap Filesystem-Watching for an Explicit Build Cache</a></p>
</li>
<li><p><a href="#heading-step-6-replace-jekylls-native-github-pages-build-with-your-own-ci">Step 6: Replace Jekyll's Native GitHub Pages Build With Your Own CI</a></p>
</li>
<li><p><a href="#heading-step-7-verify-feature-parity-not-just-it-builds">Step 7: Verify Feature Parity, Not Just "It Builds"</a></p>
</li>
<li><p><a href="#heading-what-id-tell-myself-at-the-start">What I'd Tell Myself at the Start</a></p>
</li>
</ul>
<h2 id="heading-but-wait-why-a-static-blog">But Wait, Why a Static Blog?</h2>
<p>Compared to dynamic websites, a <a href="https://www.wix.com/blog/static-vs-dynamic-website">static</a> site has a simple publishing workflow. In this case, it consists of five steps:</p>
<ol>
<li><p>Write a Markdown file.</p>
</li>
<li><p>Run the generator.</p>
</li>
<li><p>Preview and review the result.</p>
</li>
<li><p>Commit the source files.</p>
</li>
<li><p>Let GitHub Actions publish the site.</p>
</li>
</ol>
<p>This workflow is simple enough for the needs I have: occasionally publishing posts on my personal Dev blog. It keeps the content readable in a text editor and makes every change easy to review.</p>
<p>Like its predecessor, the actual codebase keeps Jinja2 <a href="https://www.geeksforgeeks.org/python/getting-started-with-jinja-template/">templates</a>, Markdown, and a YAML <a href="https://www.markdownlang.com/advanced/frontmatter.html">front matter</a> for contents. A GitHub Actions workflow builds the site and deploys the generated <code>_site/</code> directory to GitHub Pages.</p>
<h2 id="heading-why-port-a-theme-instead-of-just-using-it-as-is">Why Port a Theme Instead of Just Using It As-Is ?</h2>
<p>There are various reasons for doing it this way.</p>
<p>First, maybe you want out of a toolchain you don't use anywhere else. For me that was <a href="https://www.infoworld.com/article/2337962/whatever-happened-to-ruby.html">Ruby</a> installed on my machine for exactly and only this purpose. It was flaky enough that <em>update my blog</em> occasionally turned into <em>fix my Ruby environment</em> first.</p>
<p>Or maybe you already write in the target language daily, and would rather read and extend a generator you're fluent in than learn just enough of another ecosystem to (eventually) tweak a plugin file.</p>
<p>Or perhaps you want to understand static site generators, not just operate one. Porting forces you to read every template, every custom tag, and every build step closely enough to re-implement it. You learn and retain information differently. It's a very different level of understanding than <em>oh! it works,</em> and it's one of the best ways to achieve mastery.</p>
<h2 id="heading-what-youll-need">What You'll Need</h2>
<ul>
<li><p>Basic Python: virtual environments, reading someone else's code.</p>
</li>
<li><p>Git and a GitHub account, since the destination for both versions is GitHub Pages.</p>
</li>
<li><p>Familiarity with <a href="https://www.markdownguide.org/">Markdown</a> and <a href="https://learngitbranching.js.org/">Git</a> (you can even learn it as a <a href="https://blinry.itch.io/oh-my-git">game</a>).</p>
</li>
<li><p>Basic familiarity with Jekyll's project <a href="https://jekyllrb.com/docs/step-by-step/04-layouts/">layout</a>: <code>_config.yml</code>, <code>_layouts/</code>, <code>_includes/</code>, and Liquid template <a href="https://jekyllrb.com/docs/step-by-step/02-liquid/">syntax</a>.</p>
</li>
<li><p>No prior Jinja2 experience required. It's close enough to Liquid conceptually that you'll pick it up as you go.</p>
</li>
</ul>
<h2 id="heading-see-the-destination-first-get-the-finished-port-running">See the Destination First: Get the Finished Port Running</h2>
<p>Before I get into how the port actually came together (including the parts that broke), it's worth seeing where it ends up. The theme I'm describing already exists as a ready-to-ship project. <a href="https://github.com/hyperphantasia/tufte-python">tufte-python</a> comes with its own tutorials and you can have it <a href="https://hyperphantasia.github.io/tufte-python">running</a> locally within minutes.</p>
<p>This gives you something concrete to compare against as you read the rest of this, and something to fork if you'd rather adapt an existing port than build your own from zero.</p>
<h3 id="heading-setup">Setup</h3>
<p>First, clone it and point it at your own repository (or fork it)</p>
<p>Start by creating a new, empty repository on GitHub. Give it a name such as <code>my-blog</code>:</p>
<pre><code class="language-shell">git clone https://github.com/hyperphantasia/tufte-python.git my-blog
cd my-blog
</code></pre>
<p>Next, change the origin remote to point to your repository</p>
<pre><code class="language-shell">git remote set-url origin <your-repository-url>
</code></pre>
<p>Then push the project:</p>
<pre><code class="language-shell">git push -u origin main
</code></pre>
<p>Next, install the dependencies in a virtual environment</p>
<pre><code class="language-shell">python -m venv .venv
# Uncomment to match your OS
# source .venv/bin/activate # macOS/Linux
# .venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -r requirements.txt
</code></pre>
<p>Now you'll want to set basic configuration values before your first build.</p>
<p>Open <code>config.yml</code> at the project root:</p>
<pre><code class="language-yaml">title: "A Quiet Corner of the Web"
author: "Your Name"
email: "you@example.com"
url: "https://yourusername.github.io"
baseurl: "/my-blog" # "" instead, if this is a user/org page
permalink: "/articles/{year}/{slug}/"
theme: "solAArized"
options:
mathjax: true
</code></pre>
<p>If you're publishing at <code>https://yourusername.github.io/my-blog/</code>, <code>baseurl</code> needs to match the repository name exactly, leading slash and no trailing slash. If you get this one wrong, every internal link and stylesheet reference on the deployed site will 404 while working fine locally (more on why in the point below).</p>
<p>Finally, build and preview it:</p>
<pre><code class="language-shell">python build.py --serve --watch
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/9a27cf22-f9c7-4596-8933-a7691376adb4.png" alt="Terminal of a deployed instance of tufte-python showing the localhost." width="600" height="400" loading="lazy">
<p>Open the address the terminal prints <code>http://localhost:8000</code> (usually) and you should see the demo content that is already in <code>content/</code>. Leave <code>--watch</code> running and edit a post: the page rebuilds without you re-running anything.</p>
<p>One thing is worth knowing now before it costs you a confusing afternoon later: the local <code>--serve</code> preview ignores <code>baseurl</code> on purpose, so links and assets resolve from the root of your <em>dev</em> <em>server</em> instead of a subdirectory.</p>
<p>If you want to check the site exactly as it'll look once deployed, including the real <code>baseurl</code>: run <code>python build.py --serve --production-urls</code> instead. This is meant to preview the site using the production URL structure.</p>
<p>That distinction is the entire reason the <em>works locally, breaks in production</em> bug exists for static sites in subdirectories, and it's worth deliberately testing both modes at least once before you deploy for real.</p>
<h3 id="heading-write-your-first-post">Write your First Post</h3>
<p>You can see the theme's features render on your own content instead of the demo's. Create <code>content/posts/2024-06-07-hello.md</code>:</p>
<pre><code class="language-markdown">---
title: "Hello, Margins"
date: 2024-06-07 14:30:00
categories: notes
tags: [smile, writing]
---
{% newthought 'A new thought' %} can open a section without another heading.
Here's a sidenote{% sidenote 'note-1' 'This appears in the right margin on wide screens, and behind a tap target on narrow ones.' %} to try the feature that made me want this theme in the first place.
<!--more-->
Everything past the `<!--more-->` marker stays off the homepage excerpt but shows up on the full post.
</code></pre>
<p>Many other <a href="https://hyperphantasia.github.io/tufte-python/articles/2024/fcc-tutorial/">visual features</a> are available. They are discussed in details <a href="#the-rendered-features">below</a>, during implementation.</p>
<p>Rebuild (or let <code>--watch</code> pick it up), and you should see a small-caps opening phrase and a numbered note sitting in the margin next to the paragraph that references it. If both of those render, the theme's core mechanism is working end to end on your machine, good! This is the mechanic the rest of this tutorial is all about.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/9956118e-ce14-4b85-a6a4-8b41df5812a0.png" alt="GitHub pages section screenshot showing the GitHub actions source to deploy correctly." width="600" height="400" loading="lazy">
<p>In your repository's <strong>Settings → Pages</strong>, set the source to <strong>GitHub Actions</strong> if it isn't already. The workflow bundled with the project builds and deploys automatically on every push to <code>main</code>. I'll walk through what that workflow is actually doing in Step 6, since GitHub Pages <em>doesn't know</em> what to do with a Python build script.</p>
<p>Ship it once you're happy with it locally:</p>
<pre><code class="language-shell">git add config.yml content/
git commit -m "Configure site and add first post"
git push
</code></pre>
<p>With that running, you've got a working reference point online. Now here's how it got built.</p>
<h2 id="heading-from-tufte-jekyll-to-tufte-python-step-by-step">From tufte-jekyll to tufte-python, Step by Step</h2>
<p>To migrate a Jekyll theme to a Python build system, it's important to follow structural steps that deconstruct the existing setup.</p>
<p>Here, I determined six high-level steps, but that can vary depending on your task. It's very important to "own" the result in your mind first. This approach will enable you to consolidate a configuration and modernize the tooling with minimal breaks during the process.</p>
<h3 id="heading-step-1-inventory-the-source-themes-moving-parts">Step 1: Inventory the Source Theme's Moving Parts</h3>
<p><strong>Before</strong> writing any Python, I listed every piece of Jekyll machinery the theme actually depended on. For <a href="https://github.com/clayh53/tufte-jekyll">this</a> Liquid-heavy theme, that breaks into four categories:</p>
<table>
<thead>
<tr>
<th>Jekyll piece</th>
<th>What it does</th>
<th>Expected Python equivalent</th>
</tr>
</thead>
<tbody><tr>
<td><code>_config.yml</code> + <code>_data/*.yml</code></td>
<td>Site metadata, base URL, permalink pattern, feature toggles, structured data like social links</td>
<td>One <code>config.yml</code></td>
</tr>
<tr>
<td><code>_layouts/</code> + <code>_includes/</code></td>
<td>Page templates and partials</td>
<td>A <code>templates/</code> directory of Jinja2 templates</td>
</tr>
<tr>
<td><code>_plugins/*.rb</code></td>
<td>Ruby classes registering the theme's custom Liquid tags</td>
<td>A small Python module expanding the same tag syntax</td>
</tr>
<tr>
<td><code>_sass/*.scss</code></td>
<td>Sass partials compiled into one stylesheet at build time</td>
<td>Plain CSS files, no compile step</td>
</tr>
</tbody></table>
<p>I missed a fifth category on my first pass: the original theme ships two separate <a href="https://en.wikipedia.org/wiki/Rake_(software)">Rake</a> tasks, one for scaffolding new posts and pages, and a completely different one: <code>UploadToGithub.Rakefile</code> for pushing the built site to a <code>gh-pages</code> branch by hand.</p>
<p>This is needed because the theme's plugins aren't in Jekyll's Pages-safe <a href="https://web.archive.org/web/20140223145829/http://blog.nitrous.io/2013/08/30/using-jekyll-plugins-on-github-pages.html">allowlist</a>. I'd read the main <code>Rakefile</code> and assumed I had the whole deploy story, then wondered for some time how the original author actually got the site live.</p>
<p>Advice: <em>read the whole repository root</em>, not just the files with obvious names, before you commit to a structure.</p>
<h3 id="heading-step-2-collapse-scattered-config-into-one-file">Step 2: Collapse Scattered Config Into One File</h3>
<p>The Jekyll version spreads settings across <code>_config.yml</code> (site title, URL, baseurl, permalink pattern) and one or more files under <code>_data/</code>: a toggle for MathJax and font loading in one file, a list of social links in another. That split follows Jekyll's own data-file conventions, but it's a complexity you don't need when you're writing your own (minimal) loader.</p>
<p>I consolidated all of it into a single file with clearly named sections, so anyone extending the theme later can find every setting in one place instead of three. You get something like this:</p>
<pre><code class="language-yaml"># config.yml
# --- site metadata ---
title: "A Quiet Corner of the Web"
author: "Your Name"
email: "you@example.com"
# --- URL settings ---
url: "https://yourusername.github.io"
baseurl: "/my-blog"
permalink: "/articles/{year}/{slug}/"
# --- feature toggles (previously in _data/options.yml) ---
mathjax: true
justify_text: false
# --- social links (previously in _data/social.yml) ---
social:
- link: "github.com/yourusername"
icon: icon-github
</code></pre>
<h3 id="heading-step-3-rebuild-custom-liquid-tags-as-text-shortcodes">Step 3: Rebuild Custom Liquid Tags as Text Shortcodes</h3>
<p>This is the part that took the longest to tinker with. It's also where most of the theme's actual personality lives. This is where you actually build the visual features: sidenotes, margin figures, and epigraphs.</p>
<h4 id="heading-how-jekyll-does-it">How Jekyll does it</h4>
<p>Custom Liquid tags live in <code>_plugins/</code>, as Ruby classes Jekyll registers with its Liquid parser. Jekyll expands them during its Liquid render pass, <em>before</em> handing the result to its Markdown engine.</p>
<p>A tag like <code>{% sidenote "note-1" "Some aside." %}</code> never reaches the Markdown converter as-is. It's already been swapped for HTML by the time Markdown sees the page.</p>
<h4 id="heading-why-you-cant-just-port-this-11-into-jinja2">Why you can't just port this 1:1 into Jinja2.</h4>
<p>Jinja2 has its own tag system, but it's built for template-authoring logic (with loops, conditionals, and so on) not for parsing arbitrary quoted arguments out of prose sitting inside a Markdown file. And even if I'd built a Jinja2 extension for it, every existing post using the old <code>{% sidenote ... %}</code> syntax would need rewriting. This catch defeats the entire point of a drop-in port.</p>
<h4 id="heading-what-actually-works">What Actually Works</h4>
<p>Treat the tag syntax as plain text, and expand it with a preprocessing pass over the raw Markdown, before handing it to the Markdown renderer. The strategy is to mirror Jekyll's own tag-then-Markdown order exactly. A simplified version of that pass looks like this:</p>
<pre><code class="language-python">import re, shlex
TAG_RE = re.compile(r"\{%\s*(\w+)\s*(.*?)\s*%\}")
def split_args(raw: str) -> list[str]:
lexer = shlex.shlex(raw, posix=True)
lexer.whitespace_split = True
return list(lexer)
def render_sidenote(args, resolve_img, render_md):
note_id, text = args[0], args[1]
text = render_md(text)
return (f"<label for='{note_id}' class='margin-toggle sidenote-number'>"
f"</label><input type='checkbox' id='{note_id}' "
f"class='margin-toggle'/><span class='sidenote'>{text}</span>")
HANDLERS = {"sidenote": render_sidenote} # All visual features are registered here
def expand_shortcodes(text: str, resolve_img, render_md) -> str:
def dispatch(match: re.Match) -> str:
name, raw_args = match.group(1), match.group(2)
handler = HANDLERS.get(name)
if handler is None:
return match.group(0) # leave unknown tags untouched
return handler(split_args(raw_args), resolve_img, render_md)
return TAG_RE.sub(dispatch, text)
</code></pre>
<p>The snippet above acts as a custom "search-and-replace" engine that converts shorthand tags into HTML before the final page is rendered. It uses a regular expression to scan the text for patterns like <code>{% tag arguments %}</code>.</p>
<p><strong>The Regex (</strong><code>TAG_RE</code><strong>) is the "Scanner":</strong></p>
<p>The regex is responsible for finding the tags in the big block of text. It breaks every match into two specific groups:</p>
<ul>
<li><p>Group 1 (the name): the word immediately after {% (for example, "sidenote").</p>
</li>
<li><p>Group 2 (the raw arguments): everything else until the closing %} (for example, "note-1" "Some aside.").</p>
</li>
</ul>
<p><code>expand_shortcodes</code> <strong>is the "Coordinator":</strong></p>
<p>This function manages the overall process. It uses <code>re.sub</code> to loop through the text. Every time the regex finds a match, <code>expand_shortcodes</code> triggers the dispatch function, which does two things:</p>
<ul>
<li><p>It uses the name from Group 1 to look up the correct logic in the <code>HANDLERS</code> dictionary.</p>
</li>
<li><p>It passes the raw arguments from Group 2 into <code>split_args</code> before sending them to the parser.</p>
</li>
</ul>
<p><code>split_args</code> <strong>is the "Parser":</strong></p>
<p><code>split_args</code> uses the <a href="https://docs.python.org/3/library/shlex.html">shlex library</a> to "smart-split" the string. It recognizes quotes, so that anything inside quotation marks is kept together as a single argument. This produces a clean list where Arguments containing spaces, like a sentence inside quotes are treated as a single piece of data rather than multiple separate words (for example, ['note-1', 'Some aside.']). The final handler function can easily process that.</p>
<p><strong>Render:</strong></p>
<p>The last step is the actual rendering. Each tag name identified in the <code>HANDLERS</code> dictionary is tied to a specific Python function that knows how to return the corresponding HTML markup (for example, <code>render_sidenote()</code> for sidenotes).</p>
<p>You can have a look at the <code>.sidenote</code> and <code>.margin-toggle</code> <a href="https://github.com/hyperphantasia/tufte-python/blob/main/static/css/tufte.css">CSS classes</a>, to grasp an idea of how they behave visually.</p>
<p>Two bugs taught me why the details above matter. Both were found by throwing real old posts at the new build instead of just the demo content:</p>
<ul>
<li><p><strong>Quoting</strong> broke first. My first argument splitter was <code>raw.split()</code> on whitespace. It worked fine until I fed it a post with an apostrophe in a sidenote.</p>
<p>Example: "reader's" is problematic. It split into two arguments and shift every argument after it by one. Liquid's own <a href="https://liquidjs.com/tags/include.html#Outputs-amp-Filters">tag documentation</a> actually spells out the fix: accept either single or double quotes, and allow a backslash to escape a quote inside the text. <code>shlex</code> in POSIX mode does exactly that in about two lines, which is a smaller fix than the bug deserved.</p>
</li>
<li><p><strong>Code fences</strong> broke second. I wrote a post explaining the shortcode syntax itself, with an example wrapped in a fenced code block. This is a case of context-blindness. The regular expression is designed to find the pattern <code>{% ... %}</code> anywhere it appears in the document, but it doesn't know the difference between "live" code that should be executed and "example" code that is just meant to be displayed as-is to the reader. <code>The expand_shortcodes</code> function sees the <code>{% and %}</code> inside that code block and says, "Aha! A visual feature!" It then replaces the example text with the actual HTML for a sidenote and you end up seeing a broken layout where a functional feature is floating inside a code block.</p>
<p>The fix is to stash fenced and inline code spans behind placeholders (like <code>##CODEBLOCK_1##</code>) before running the tag regex, then restore them afterward.</p>
</li>
</ul>
<h4 id="heading-the-rendered-features">The Rendered Features</h4>
<p>The margin is not <em>decoration.</em> The Tufte-inspired layout remains readable thanks to the restrained typography and a generous margin set for supporting materials.</p>
<p>Secondary information moves into the margin instead of becoming a long interruption in the body of the article. It gives other visual elements such as notes, references, and figures a unique place to live without interrupting the main argument.</p>
<p>From there, porting the rest of the tags was repetitive and mechanical: same pattern, a different handler and argument count each time, the entire code is available in this <a href="https://github.com/hyperphantasia/tufte-python/blob/main/tufte_ssg/shortcodes.py">file</a> and this is how they render:</p>
<h4 id="heading-new-thought">New Thought:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/e05dfc4d-a76d-41fa-80e4-b6dc65413683.png" alt="Tufte-Python: NewThouht example screenshot." width="600" height="400" loading="lazy">
<ul>
<li>Liquid tag: <code>{% newthought 'text' %}</code></li>
</ul>
<h4 id="heading-sidenote">Sidenote:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/879c459d-085e-4b4b-9341-5a4dcb34341b.png" alt="Tufte-Python: sidenote example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% sidenote 'id' 'text' %}</code></p>
<p>Sidenotes are numbered aside in the right margin.</p>
</li>
</ul>
<h4 id="heading-margin-note">Margin note:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/312efc73-1f49-4774-997c-fec4b490de9b.png" alt="Tufte-Python: margin note example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% marginnote 'id' 'text' %}</code></p>
<p>Margin notes are unnumbered aside in the margin.</p>
</li>
</ul>
<h4 id="heading-margin-figure">Margin figure:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/2a2d7d4c-7fc4-499b-a18d-19a159efe33f.png" alt="Tufte-Python: Margin figures example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% marginfigure 'id' 'path' 'caption' %}</code></p>
<p>The supporting image is confined to the margin column. Handling images isn't a big challenge, since HTML provides <code>img</code> tags. Positioning them correctly within the viewport is bit more tricky but was already handled well by the original SCSS.</p>
</li>
</ul>
<h4 id="heading-main-column-figure">Main column figure:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/5b467bb2-0dec-4a71-8013-c06c7214d5b9.png" alt="Tufte-Python: Main column figure example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% maincolumn 'path' 'caption' %}</code></p>
<p>The main image is confined to the main text column.</p>
</li>
</ul>
<h4 id="heading-full-width-figure">Full-width figure:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/7a1bfc24-c28b-4b70-8c0f-d0cc670075c7.png" alt="Tufte-Python: full width figure example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% fullwidth 'path' 'caption' %}</code></p>
<p>The full image spans on both columns.</p>
</li>
</ul>
<h4 id="heading-epigraph">Epigraph:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/7c49ef48-1f20-4b08-a5dd-d5b9a183b77a.png" alt="Tufte-Python: epigraph example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% epigraph 'quote' 'author' 'source' %}</code></p>
<p>This is meant for a standalone attributed quotation.</p>
</li>
</ul>
<h4 id="heading-math">Math:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/971ddc29-ad59-4d60-b09d-0f6c51f2ad1d.png" alt="Tufte-Python: MathJax example screenshot." width="600" height="400" loading="lazy">
<ul>
<li><p>Liquid tag: <code>{% math %} ... {% endmath %}</code></p>
<p>This is pure block <a href="https://en.wikipedia.org/wiki/LaTeX">LaTeX</a>, rendered via <a href="https://math.meta.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference">MathJax</a>.</p>
</li>
</ul>
<p>You can also use standard markdown features, like code snippets:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/f5eff9c6-53cd-4b39-8431-f32400f399c8.png" alt="Tufte-Python: code snippet example screenshot." width="600" height="400" loading="lazy">
<p>or tables:</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/6b8b573c-0780-4822-b20d-42b3e801ee5c.png" alt="Tufte-Python: table example screenshot." width="600" height="400" loading="lazy">
<p>These last two elements were easier to implement. Since they render in pure Markdown, it really is just about handling them directly in the CSS style sheet (for example, the Table styling section in the <a href="https://github.com/hyperphantasia/tufte-python/blob/main/static/css/tufte.css">tufte.css</a> file).</p>
<h3 id="heading-step-4-replace-compiled-sass-with-swappable-plain-css">Step 4: Replace Compiled Sass With Swappable Plain CSS</h3>
<p>Jekyll's Sass pipeline compiles <code>_sass/</code> partials into a single stylesheet at build time, baking one fixed color palette into the output.</p>
<p>I didn't want a Sass-compilation dependency just to port a theme, so I stopped compiling colors into CSS.</p>
<p>The plain CSS stylesheet comes into two layers: structural CSS that <em>never hardcodes a color</em>, only references custom properties like <code>color: var(--color-text)</code>, and one small theme file per palette that defines nothing but <code>--color-*</code> properties. The build copies just the selected theme's file into the output, based on a <code>theme:</code> key in <code>config.yml</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/a8688624-19bb-4b22-acdc-4d9b4c9e5caa.gif" alt="Tufte python ssg animated screenshot of the available accessible themes. " width="600" height="400" loading="lazy">
<p>Custom themes were a <strong>big improvement</strong> I wanted to implement. This turned into more than a workaround once I actually checked the numbers. I'd defaulted to <a href="https://ethanschoonover.com/solarized/">Solarized</a> first because I liked it, and only later discovered it's not <a href="https://innocen.at/2020/05/on-solarized-and-why-i-stopped-using-it/">optimal</a> in terms of <a href="https://www.johnsy.com/blog/2025/12/15/accessible-colours-over-solarized/">accessibility</a>. That's a known, documented property: it trades some contrast for reduced eye strain.</p>
<p>Shipping it as the <em>default</em> without flagging it felt wrong for something other people might actually use to read.</p>
<p>Since the theme system is just swappable CSS files, the fix was adding one more file: a <a href="https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html">WCAG 2.0 AA</a> accessible variant with the same palette adjusted to clear 4.5:1 contrast, alongside the original. That's the option this tutorial's config example points at: <a href="https://github.com/paulcpederson/solAArized">solAArized</a>.</p>
<p>The custom-properties approach <em>paid off</em> again a moment later: because colors are resolved at runtime by the browser instead of baked in at build time, adding a light/dark toggle driven by <code>prefers-color-scheme</code> was just a small <a href="https://github.com/hyperphantasia/tufte-python/blob/main/static/js/theme-toggle.js">JS file</a> to wrap. This is something a Sass-compiled single palette can't do without recompiling twice.</p>
<h3 id="heading-step-5-swap-filesystem-watching-for-an-explicit-build-cache">Step 5: Swap Filesystem-Watching for an Explicit Build Cache</h3>
<p><code>jekyll serve -w</code> bundles file-watching and incremental regeneration. Incremental involves tracking the actual state.</p>
<p>My first cache just tracked each post's own modification time: unchanged file, skip re-rendering. That's correct right up until you edit a shared template. I changed the post layout, rebuilt, and only two of my posts picked up the change: the ones I'd also touched that day. The others were "unchanged" by the only definition the cache knew about, so they kept their stale, pre-edit HTML in <code>_site/</code>.</p>
<p>The fix is a second, separate timestamp that isn't tied to any one document: track the newest modification time across <em>global</em> build inputs: templates, <code>config.yml</code>, and the generator's own source. If any of those is newer than the cache, force a full rebuild regardless of what any individual post's timestamp says.</p>
<pre><code class="language-python">import json
from pathlib import Path
CACHE_FILE = Path(".build_cache.json")
def load_cache() -> dict:
if not CACHE_FILE.exists():
return {"global_mtime": 0.0, "docs": {}}
return json.loads(CACHE_FILE.read_text())
def needs_rebuild(src: Path, out: Path, cache: dict, global_stale: bool) -> bool:
if global_stale or not out.exists():
return True
cached_mtime = cache["docs"].get(str(src))
return cached_mtime is None or src.stat().st_mtime > cached_mtime
def save_cache(cache: dict, docs: dict) -> None:
cache["docs"] = docs
CACHE_FILE.write_text(json.dumps(cache))
</code></pre>
<p>The <code>load_cache()</code> function reads a saved JSON file that remembers when each document was last modified or it creates a fresh empty cache if the file doesn't exist yet.</p>
<p>The <code>needs_rebuild()</code> function checks whether a source file actually needs to be rebuilt by comparing its current modification time with the timestamp stored in the cache. If the file is newer than what's cached, or if the output file doesn't exist, it returns <code>True</code> (meaning "rebuild needed").</p>
<p>Finally, <code>save_cache()</code> updates the cache with the new build information and saves it back to the JSON file, so next time you run your build, you can skip files that haven't changed.</p>
<p>There's no cheap way to know <em>which</em> pages a shared template actually touches without re-parsing everything, so I stopped trying to be clever about it. It costs one slower build after a template edit but that's in exchange for never silently shipping a page that looks like it built successfully but didn't actually pick up the change.</p>
<h3 id="heading-step-6-replace-jekylls-native-github-pages-build-with-your-own-ci">Step 6: Replace Jekyll's Native GitHub Pages Build With Your Own CI</h3>
<p>GitHub Pages knows how to build Jekyll natively. It has no idea what <code>python build.py</code> means, so the port needs its own CI step to build the site and hand the output to Pages:</p>
<pre><code class="language-yaml"># .github/workflows/deploy.yml
name: Build and deploy site
on:
push:
branches: [main]
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
permissions:
pages: write
id-token: write
steps:
- uses: actions/deploy-pages@v4
</code></pre>
<p>What happens? When you push, GitHub's servers automatically run the build job, which checks out your code, installs Python 3.12, downloads the project dependencies (from <code>requirements.txt</code>), runs <code>build.py</code> to generate the website, and then uploads the generated <code>_site</code> folder as an artifact.</p>
<p>After that succeeds, the deploy job automatically runs and takes that artifact to publish it live to GitHub Pages. Note the <code>needs: build</code> line. It validates the deploy step only happens after the build completes successfully, so you can't accidentally deploy a broken build.</p>
<p>This is the workflow the <a href="#write-your-first-post">quickstart</a> earlier in this piece relies on. Remember that in <strong>Settings → Pages</strong>, the source has to be set to <strong>GitHub Actions</strong> rather than a branch (this replaces Jekyll's built-in build step entirely). I missed that setting the first time and spent a few minutes convinced the workflow had silently failed, when it had actually succeeded and just had nowhere configured to deploy to.</p>
<h3 id="heading-step-7-verify-feature-parity-not-just-it-builds">Step 7: Verify Feature Parity, Not Just "It Builds"</h3>
<p>A port that compiles cleanly isn't necessarily a correct one. Every bug I've described above passed a clean build first. Before I called it done, I tested against:</p>
<ul>
<li><p><strong>Real, unmodified posts from the original theme</strong>, not just demo content. This is what actually caught the quoting bug and the code-fence bug, neither of which showed up until I stopped testing against content I'd written specifically to be easy.</p>
</li>
<li><p><strong>Quoting edge cases</strong> deliberately: an apostrophe inside a note, Markdown formatting inside a note, an escaped double quote.</p>
</li>
<li><p><strong>Responsive behavior</strong>, since sidenotes and margin notes that <strong>tap-to-reveal</strong> on narrow screens are easy to get right on desktop and silently break on mobile versions.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/67c84561e3f229edf2351ba2/47a91b88-357d-4979-a15f-e46414d61c7c.gif" alt="Tufte python powered blog displaying a responsive state." width="600" height="400" loading="lazy">
<p>Responsive design is sometimes neglected and definitely not an option regarding nowadays devices diversity. Always consider it as a full and distinct user experience.</p>
<h2 id="heading-what-id-tell-myself-at-the-start">What I'd Tell Myself at the Start</h2>
<p>Every real bug in this port came from the same root cause: testing against content I'd written to be easy, instead of content that already existed.</p>
<p>Don't have opinions about how the old tags should behave. The fix, every time, was the same instinct: go find the actual edge case in the old repository's documentation and code, rather than guessing at what "probably" needs to be supported.</p>
<p>The steps themselves generalize past this one theme: inventory the source generator's moving parts, consolidate its config, re-implement custom tags as a text-preprocessing pass instead of fighting your new template engine's syntax, swap compiled styling for something your new stack can produce without extra tooling, write your own incremental cache with an explicit escape hatch for global changes, replace whatever native deploy step you're leaving behind with your own CI, and verify against real content, not a clean build. That holds whether you're moving from Jekyll to Python, Python to Go, or anywhere else.</p>
<p>Thanks for reading! Feel free to contribute to <a href="https://github.com/hyperphantasia/tufte-python">tufte-python</a>! I'm very curious about what you can come with to make this library better. More personal projects are available on my <a href="https://github.com/hyperphantasia">GitHub</a> and <a href="https://kaggle.com/grimespoint">Kaggle</a>. You can also connect with me directly on <a href="https://www.linkedin.com/in/to-b-one">LinkedIn</a> as well.</p>