Martin O'Hanlon's project blog — code, hardware and project notes. A Jekyll site hosted on GitHub Pages.
Project structure
├── _config.yml site config, permalinks, plugins, pagination, analytics ID
├── index.html the home list (paginated, 7 per page)
├── 404.html served by Pages for any unmatched path
├── search.json build-time search index (title, url, date, tags, first 200 words)
│
├── .github/workflows/
│ └── deploy.yml builds on push to main and publishes to Pages
│
├── .claude/skills/
│ └── crosspost-medium/ Medium → draft converter and the process around it
│
├── _posts/ one file per post — YYYY-MM-DD-slug.md
├── _pages/ standalone pages — about, raspberry-pi, minecraft, …
├── _drafts/ unpublished work, no date in the filename
├── search/label/ redirect stubs from the old Blogger label URLs
│
├── _layouts/
│ ├── default.html header, content/sidebar grid, footer
│ ├── home.html the post list — also serves the tag pages
│ ├── post.html single post
│ ├── page.html standalone page
│ └── tag.html generated per label at /tags/:slug/
│
├── _includes/
│ ├── head.html meta, fonts, stylesheet, theme bootstrap
│ ├── header.html wordmark, nav, theme toggle
│ ├── sidebar.html search, labels, pages
│ ├── analytics.html GA4 — production builds only
│ ├── footer.html
│ ├── list-screen.html list heading + rows + pager (home and tag pages)
│ ├── post-list.html the post rows themselves
│ ├── youtube.html {% include youtube.html id="…" %}
│ └── embed.html {% include embed.html src="…" title="…" %}
│
├── _data/
│ ├── tags.yml label slug → display name, where the slug will not do
│ └── about_links.yml the link cards on /about/
│
├── assets/
│ ├── css/main.css the whole stylesheet
│ ├── js/site.js theme toggle, copy buttons, search
│ ├── img/YYYY/MM/ post images, self-hosted
│ └── favicon.ico
│
├── _import/ one-off tooling from the Blogger migration
└── archive/ the original Blogger export
_import/is finished work, not part of running the site. Its scripts regenerate_posts/,_pages/and_drafts/from the old Blogger export, deleting whatever is there first. Once you have written a new post, never run them — you would lose it. Nothing in normal use needs that directory.
Installation
You need Ruby (3.3 or newer) and Bundler. That is all — the site has no Node or npm dependency.
bundle install
Currently building against Ruby 3.3.5, Bundler 2.5.16, Jekyll 4.4.1.
Plugins, all from the Gemfile: jekyll-redirect-from, jekyll-feed,
jekyll-sitemap, jekyll-paginate-v2. Don't add more without a good reason —
the build runs on GitHub Actions precisely so the plugin list stays ours.
Development
Run the dev server
bundle exec jekyll serve --livereload --draftsThen open http://localhost:4000. --drafts includes _drafts/;
--livereload refreshes the browser on save.
Useful flags:
| Flag | Why |
|---|---|
--drafts |
show unpublished posts from _drafts/ |
--livereload |
reload the browser on save |
--incremental |
faster rebuilds while writing (can miss changes; restart if something looks stale) |
--future |
show posts dated in the future |
--port 4001 |
if 4000 is taken |
On Windows, --detach does not work — Jekyll uses fork(), which Windows
lacks. Leave the server running in its own terminal.
Build
bundle exec jekyll buildOutput goes to _site/, which is generated and git-ignored — never edit it.
To preview a finished build without Jekyll:
cd _site
python -m http.server 4001Open http://localhost:4001.
Branches
Do all work on development. It is the default branch, so a fresh clone
lands there and a stray commit cannot publish anything by accident.
main is the published site. Nothing deploys until development is merged into
it:
git switch development # write, commit, push as often as you like — none of this is live git push # when it is ready to go out git switch main git merge development git push # this is what deploys git switch development
Drafts in _drafts/ are excluded from the build anyway, so unfinished posts are
safe on either branch. The branch split matters more for theme and config
changes, where a half-finished edit on main would go straight to the live
site.
Deployment
Push to main. .github/workflows/deploy.yml
builds the site and publishes it to GitHub Pages. You can also run it by hand
from the Actions tab.
The workflow only triggers on main, so pushing to development never
deploys.
git checkout main git pull git merge development git push origin main
The build runs in Actions rather than on Pages' own Jekyll, which is pinned to an old version and only allows a fixed set of plugins.
Two things the workflow handles that are easy to get wrong by hand:
JEKYLL_ENV=production. The analytics snippet is only emitted for a production build. Without this it is silently left out — the deploy goes green and the dashboard stays empty.--baseurlcomes from the Pages configuration, not the config file. It is empty once a custom domain is attached, and/<repo>while deploying to a project site, so asset URLs stay correct either way.
Before publishing, the workflow checks the build and fails rather than shipping
if: a stylesheet, script or search.json came out as an HTML document; 404.html,
feed.xml or sitemap.xml is missing; fewer than 136 posts or 136 redirect
stubs were produced; or any page still points at googleusercontent.com.
Those are all failures that have actually happened here, not hypotheticals. The stylesheet one in particular returned HTTP 200 with a plausible file size while applying no styling whatsoever.
First run: in the repository settings, set Pages → Build and deployment → Source to GitHub Actions. The first push then deploys.
How to
Write a post
Create _posts/YYYY-MM-DD-some-slug.md:
--- title: 'Raspberry Pi - reading a DHT22 sensor' date: 2026-08-01 20:15:00 +01:00 tags: [python, raspberry-pi] --- Opening paragraph. This becomes the excerpt on the home page and the description in search results, so make it say something. ## What you need - a Raspberry Pi - a DHT22 sensor ```python import board import adafruit_dht sensor = adafruit_dht.DHT22(board.D4) print(sensor.temperature) ```
- The URL comes from the filename minus the date:
/posts/some-slug/. - No
layout:needed — posts get it automatically. - The date needs a UTC offset:
+01:00for BST,+00:00for GMT. Without one the post can land on the wrong day. tagsare lowercase slugs —raspberry-pi, notRaspberry Pi.- Headings inside a post start at
##. The theme prefixes everyh2with a green##marker, which is a deliberate part of the design.
Fence every code block with a language
```python, ```bash, ```html, and so on. Never a
bare ```. The language drives both the syntax colours and the label in
the block's header bar. Use text for console output or anything that isn't a
real language.
Code is never wrapped — long lines scroll sideways. Indentation is meaning in Python posts, so leave it alone.
Add images
Put the file in assets/img/YYYY/MM/ matching the post's date, then reference
it with an absolute path:

- Self-host everything. Don't hotlink — the old blog is full of images that have since rotted away on other people's servers.
- Resize to about 1600px wide before committing. Anything larger is wasted.
- Lowercase filenames, hyphens not spaces.
Embed a video
{% include youtube.html id="dQw4w9WgXcQ" %}Anything else that needs an iframe:
{% include embed.html src="https://…/embed" title="What it is" %}Start a draft
Put it in _drafts/ with no date in the filename — _drafts/my-idea.md.
It shows up under jekyll serve --drafts and is skipped by a normal build.
Publishing means moving it to _posts/ and adding the date to the filename.
Cross-post something published elsewhere
Add canonical_url to the front matter, pointing at the original:
--- title: 'Reading a DHT22 sensor' date: 2026-08-01 20:15:00 +01:00 tags: [python, raspberry-pi] canonical_url: https://other-site.example/blog/reading-a-dht22/ ---
That is the whole mechanism. _includes/head.html emits
<link rel="canonical"> pointing at canonical_url when it is present and at
the post's own URL when it is not, so existing posts are untouched.
- Give the full URL, with scheme —
https://…. Jekyll leaves an already absolute URL alone, so it is written out verbatim. - The point is to tell search engines which copy is the original, so they credit it rather than treating your copy as duplicate content competing with it.
og:urldeliberately still points at your page. That is what social platforms link and unfurl, and a share of your copy should land on your copy.- Nothing appears on the page itself — it is a
<head>tag only. If the original site's terms expect a visible credit, write that line into the post body yourself.
Check it came out right after a build:
grep canonical _site/posts/reading-a-dht22/index.html
Cross-post from Medium
Use the /crosspost-medium/ skill to cross-post a Medium blog:
crosspost-medium https://medium.com/link-to-post canonical link - https://link.to.original/post
To run the converter yourself:
python .claude/skills/crosspost-medium/medium_to_md.py <medium-url> --canonical <canonical-url> --tags graphrag,neo4j
The two URLs are usually different. The Medium URL is what gets converted; the
canonical is whoever should get the credit, often a third site. Omit
--canonical and it falls back to Medium's own.
You get _drafts/YYYY-MM-DD-slug.md, the images self-hosted under
assets/img/YYYY/MM/, and a review list of what it would not decide for you —
fence languages, alt text, tags, embeds. Work through that, check it with
python _import/verify.py, then publish with a plain move, since the draft is
already dated:
git mv _drafts/2026-07-22-my-post.md _posts/2026-07-22-my-post.md
SKILL.md in that directory has the full process and the reasoning. Other flags:
--dry-run, --force, --credit-name, --no-credit, --self-test.
Add a page
Create _pages/thing.md, which becomes /thing/:
--- title: 'Thing' --- Body copy.
To put it in the sidebar's Pages list, add a link in
_includes/sidebar.html. To add it to the top nav, edit
_includes/header.html.
Use tags
Add slugs to a post's tags: and the tag page at /tags/<slug>/ is generated
automatically, along with the chips under the post.
That is all a new tag needs. The sidebar's Labels list and the 404 page's
chips are both built from site.tags by
_includes/tags-by-count.html, so a label appears
as soon as a post uses it, ordered by post count, with the count calculated at
build time. Nothing to maintain by hand.
_data/tags.yml is only a display-name lookup, for the names a slug cannot
express — it is how /tags/csharp/ displays as c# and dotnet as .net. A
label with no entry displays as its own slug, which is usually what you want. Add
one when it is not:
- slug: dotnet name: '.net'
Ties in the count are broken alphabetically, so the order is deterministic.
Turn analytics off (or change the property)
The GA4 measurement ID lives in _config.yml:
google_analytics: G-XXXXXXXXXX
Blank it to disable tracking entirely. It is never emitted by a local build, so
jekyll serve will not report your own browsing as traffic — only the
production build in Actions includes it.
Change the look
Everything is in assets/css/main.css. Colours are oklch() custom properties
defined twice at the top — once for dark, once under
:root[data-theme="light"]. Change a token there and it applies everywhere.
If you ever add a file to
assets/that needs front matter, give itlayout: null. Front matter makes Jekyll treat a file as a page, and a page gets wrapped in a layout — which silently serves a stylesheet as an HTML document._config.ymlhas a default guardingassets/; keep it.
Things not to break
- Never remove a
redirect_fromentry. Each one keeps an old Blogger URL working, and some of those links are over a decade old. - Permalinks are
/posts/:slug/. Renaming a post file changes its URL. - Two fonts only — JetBrains Mono for chrome, headings and code; IBM Plex Sans for post prose.
- No icon library. Every glyph in the design is a text character:
←→↗▍☀☾$/#.
Checking your work
python _import/verify.py
A content linter, safe to run any time — it reads files and writes nothing. It
reports untagged code fences, missing front matter, posts still pointing at
remote images, and assets accidentally wrapped in a layout. Needs
pip install beautifulsoup4 tzdata.