❯~/pixelastic

Migrating from Jekyll to Eleventy

I started this blog in 2009. At the beginning, I posted a lot, mostly as a way to document my troubles for others (and for myself). Along the year, I moved away from writing, not because I had nothing to say anymore, but because I went more and more into public speaking instead.

Recently, AI-assisted blog writing made me want to come back to sharing my prose online, and while I was at it I decided to migrate the stack from Jekyll to Eleventy, and redesign the blog while I was at it.

This post is an attempt at documenting that process that took me about 2 days, where I would have probably spent months on it before.

Picking an engine

This repo has 320 posts and ran on Jekyll 3.6, with Ruby, Grunt, Bower, Sass, and even jQuery. I wanted to move to a more modern stack, and the choices I had in mind were between Hugo, Astro and Eleventy.

I had already tried Hugo on painting.pixelastic.com, and now I wanted to compare with something else. Hugo is really as fast as people say, but I don't know how to write good Go, and I wanted to test a JavaScript contender, where I have more experience.

I decided to not try Astro this time mostly because I had wanted to try Eleventy for a long long long time. The fact that it's still relevant today, is a sure sign it's a solid stack. Next time I need to migrate a static website I'll try Astro, I promise.

Part 1: migrating the code

Here I called my custom /plan skill, which has the agent write a PRD and split it into 9 individual issues covering the skeleton, content migration, tags, archives, feeds, tests, the Tailwind layout, and various cleanups.

I kept the redesign out of scope here (see part 2), the goal was to have the default Eleventy layout, but with all my content.

Thoughout the migration, it also fixed some inconsistency I had in my posts: mix of Windows/Linux line ending, mixed case tag collision, and even duplicated posts at various dates.

Once done, no more Ruby in the codebase, only JS. Note that I didn't review the code there; my goal was having something that worked, so I could keep posting. I told myself I would review the code later.

Part 2: the redesign

For the reddesign I first made a /grill-me session with Claude, starting with a very vague setup ("I want to redesign my blog into something more modern"), then through questions we managed to nail it down more precisely.

This question/answer loop made it emerge that I wanted something dark-themed, reminiscent of the time I spend at a terminal, re-using some of the visual language of a CLI (like the ~/pixelastic header), and using my real neovim colorscheme.

Claude then transformed that into a prompt for v0, that I pasted 4 times into 4 different v0 windows:

Version 1: monospace post titles with underline, a green "$ ls posts/" label and plain-text tags

Version 2: bold post titles and tags as bordered chips, with a prompt arrow next to the site name

Version 3: a large "Notes from the command line" hero in thin monospace, with a cyan highlight

Version 4: a large "Things I learned while building" hero in bold sans-serif, with breadcrumbs

This approach worked well for me because I felt I was one of those horrible customers coming to see a designer with nothing more than "I don't know what I want, but I'll now when I'll see it". Thankfully, the AI doesn't shy from those requests, so seeing 4 different iterations made me realize what is important for me and what I don't like.

Out of the 4 versions above, version 2 was the one I liked the most. I then exported the zip from V0 and told Claude to integrate it into Eleventy.

From there, I did a lengthy chat with it, telling it one by one everything I wanted to change, waited for it to change, then move to the next item. Those were things like:

  • make the main column wider on desktop;
  • make sure titles do not overflow that column;
  • reuse the colors from my own colors.jsonc in my dotfiles;
  • move some items from the header to the footer.

Here, I didn't follow a clear /plan like in the first part. It really was an "old-school" prompting session of "do this", "no, do that instead", "actually I liked the first version better".

This is just the beginning

Now that the migration is done, I still have some work to do. For example, I still care about the generated code and I want to review it, to understand how Eleventy works, and what's the best way to organize my code in it.

I will also fix small reading issues here and there, improve the mobile experience, migrate the site to Netlify, etc.

Making my agent find my own CLI helpers

Over the years, I made myself a long list of CLI helpers. Stuff like git-worktree-create or video-volume-increase. They help me get the info I want or do the action I need without having to remember the exact underlying command and args to type.

But when I ask Claude to create a branch or get me the dimensions of a video, it would use bare git or ffmpeg commands. That inconsistency buggued me at first but I thought that given the agent was smarter than I was, it didn't need an alias, it could just remember the complete and complex command so it didn't really matter.

Well, it mattered, actually

When I ask the agent for a new worktree, what I have in mind is running my custom git-worktree-create helper. I packed a lot of complexity away behind that command:

  • It stops early when the current directory is not a git repository.
  • It starts a new branch.
  • It creates the worktree folder in a shared directory, with a naming convention.
  • It handles git submodules.
  • It installs dependencies.
  • It handles edge cases (invalid chars in branch names, etc.).
  • It also takes care of some edge cases or additional tweaks for some specific repos.

This command started as a simple git worktree add /worktree/dir branch-name wrapper but grew over time to the complex beast it is today. And its tests grew with it. That command does exactly what I want it to do, and I have tests to prove it now. I feel safe using it, knowing it will do exactly what I expect it to do.

Letting the agent know

I'd like the agent to use that helper. But the agent doesn't know about it. It still thinks "Creating a worktree" is running git worktree add /worktree/dir branch-name and that's it.

I found myself having to tell it: "Use the git-worktree-create helper instead." often. And I had to do that for all my helpers.

I started adding lines into my CLAUDE.md to tell it about my favorite helpers but I quickly realized it wouldn't scale with the 1000+ helpers I currently have.

What I did instead was writing two other helpers. helper-list would list all my helpers and helper-documentation would explain how to use on.

helper-list

helper-list takes a query and returns only the helpers that match that query.

A terminal running . It prints about 25 helpers, from  to . Each helper name sits in orange, next to a one-line description in white.

All my helpers follow a consistent naming scheme of <domain>-<object>-<action>. For example git-branch-create, docker-image-list, notion-page-read, etc.

Finding them is a matter of piping find into fzf to get some fuzzy-matching against the query and displaying them. As my helpers have explicit enough names, the agent can guess which one would be useful to it.

helper-documentation

Knowing that a helper exists is only the first step. Now the agent needs to know how to use it. helper-documentation takes a helper name as input, and returns its header documentation comments (including description, usage, etc).

A terminal running . It prints the helper description, "Create a Worktree for a branch (new or existing) and cd into it", then a usage line with the example .

By making sure all my helpers are properly documented, documentation usage can easily be resurfaced to the agent.

CLAUDE.md

To wrap this all together, here is what I added to my CLAUDE.md:

Prefer dedicated CLI helpers over raw commands.
Examples:


| Instead of…              | Use…                         |
|--------------------------|------------------------------|
| `git` complex pipelines  | `git-*` helpers              |
| `convert`, `imagemagick` | `img-*` helpers              |
| `ffmpeg` pipelines       | `audio-*`, `video-*` helpers |


Discover helpers with `helper-list <domain> [action]`.
Read the flags and usage of a helper with `helper-documentation <name>`.

The result

Now, I can just focus on adding more helpers and more helpers, following my naming convention and documenting them properly. I'm assured my agent will be able to use them without me having to update anything in my CLAUDE.md. Those helpers are useful to both me and the agent.

A helper that finds my other helpers feels a bit meta, but it works really well.

One more benefit is that I can now configure Claude to allow specific commands (like git-worktree-create) without needing to give it a blanket permission to the whole git command.

Saving my kitty workspace every two minutes

Yesterday, my Ubuntu laptop ran an update of packages, and once it was done, it let me know I needed to reboot my laptop for the change to be completed. But I didn't want to reboot my laptop right now. I had 12+ open tabs in Kitty, each split into subwindows because I was working on various topics.

So I postponed the reboot. And postponed; for days. Finally I gave up, wrote down the list of tabs, rebooted, and manually restarted all my kitty tabs.

A kitty window with ten named tabs. The current tab is split in two columns, and the right column is split again.

Doing it by hand

It was tedious, and I didn't really like doing it. And I certainly didn't want to have to do it twice. I wanted a keybinding to do it for me. Something that would save the whole layout, and bring it back.

Since Kitty 0.43, the save_as_session action dumps tabs, tab names, splits, layout geometry, and working directories to a file. And kitty --session <file> starts from that file.

I updated my Kitty to the right version and created a kitty-save script that dumps the session to a file, and bound it to Alt-F5. I also made it flash the screen green when I save (so I have some visual feedback something actually happened).

# kitty.conf
allow_remote_control socket-only
listen_on unix:/tmp/kitty-socket

map alt+f5 launch --type=background kitty-save
#!/usr/bin/env zsh
# kitty-save
sessionFile="$HOME/.local/state/kitty/session.kitty-session"

# Talk to kitty through its socket
kitty-remote() { kitty @ --to unix:/tmp/kitty-socket "$@" }

# Flash all windows green
kitty-remote set-colors --all background='#1a3a1a'

# Dump the session to a file
kitty-remote action save_as_session --save-only "$sessionFile"

# Back to the normal colors
kitty-remote set-colors --all --reset

I also created a kitty-restore script that checks for the existence of the session file, and appends --session with it if the file exists. I call that script now instead of opening Kitty directly.

#!/usr/bin/env zsh
# kitty-restore
sessionFile="$HOME/.local/state/kitty/session.kitty-session"

sessionArgs=()
[[ -f "$sessionFile" ]] && sessionArgs+=(--session "$sessionFile")

kitty "${sessionArgs[@]}"

I was happy. On the next reboot, I could just Alt+F5 to save everything, reboot, and come back where I left off.

The crash

But the next time my computer rebooted, it was because it crashed. Totally unrelated, but the computer froze and I had to manually reboot it, without being able to press Alt-F5 first.

When I restarted Kitty, it came back with the layout from my last manual save, which was days old. My tabs were not the ones I had been working on.

So, manual saving was a good start, but wasn't enough. I needed something more automated.

Every two minutes

I thought about running kitty-save automatically every two minutes. But in my mind it meant writing cron files, with their weird syntax made of * and / and that seemed too complex to me at that time.

Then I remembered we live in an age of agents and I wouldn't be the one writing those commands anyway, so why not ask the agent? Actually the agent told me that cron was outdated and systemd was apparently the new way of doing it.

A systemd user timer is made of two small files: a service to say what to run, and a timer to say when.

# kitty-save.service
[Service]
Type=oneshot
ExecStart=/path/to/kitty-save
# kitty-save.timer
[Timer]
OnStartupSec=2min
OnUnitActiveSec=2min

[Install]
WantedBy=timers.target

OnStartupSec tells it to start its first tick 2mn after login. OnUnitActiveSec tells it to run again after that every 2mn.

I had to slightly update kitty-save to work both when called manually and through the timer. I made the window green flash optional behind a --flash flag, and added some guards to return early if kitty wasn't even running.

Peace of mind

I now have this automated save running in the background every 2mn. It takes about 90ms to run, but as it runs asynchronously in the background, I don't even perceive it. And now a crash would cost me 2mn of Kitty layout at most.

Keeping the context of an Airtable Record Picker from page to page

Isometric illustration of a map pin standing on layered cards

I'm using Airtable Interfaces a lot, to pilot my automations. For a project at work I built a whole pipeline to help us organize meetups in our office. It starts with the URL of a meetup.com event, and ends with the printing of the signs to put in the building, and goes through all the phases of calculating the number of pizzas needed, booking rooms in the corporate calendar, printing badges for attendees and pre-generating internal Slack messages, etc.

Airtable interface page for the React Paris Meetup 5 with attendee numbers and food estimates

But the process is made of multiple steps, that having all the info on one page was getting overwhelming and very practical, so I started splitting it into several pages. One page would handle the attendee list, another would take care of the calendar, another of the Slack messages, etc.

Airtable interface sidebar of the Meetup Hosting app, with the Attendees and Food page selected

The issue I ran into though, was that whenever I would switch pages, I would lose the context of which meetup I'm currently editing. For example I would start on the "Metadata" page for the upcoming React Meetup; I would then click on the "Calendar Events" page and would somehow now be editing the upcoming Algolia Search Party.

This is because, from Airtable Interfaces point of view, each page is an independent context, and there is no default way to keep the currently selected Event from one page to the next.

My hackish workaround

Airtable table of meetups with a pin trigger URL column and an is pinned checkbox

Meetup picker dropdown next to a blue Pin button

The (very hackish and imperfect) way I found around this is to add a isPinnedInUI checkbox field to my table. Only one element of the table should be marked as pinned at any given time. I also configured the record picker to select the fields where isPinnedInUi is true in priority.

I also added a custom Make scenario (trigger when clicking on the Pin blue button next to the record picker) that would uncheck the previously checked element, and check the currently selected one.

That way, when I'm working on an event I can pin it in place, and keep its context even when navigating from page to page.

Of course, this is just a bandaid, and fails as soon as more than one person is working on the dashboard at the same time. If I start pinning a meetup and my coworker Lucas is working on another one and pin the other, we'll just step on each other's toes. But so far, it has been an improvement over completely losing the context.

Compression strategy for heavy Whisper files

Abstract green sound waveform made of ink splatters

I'm often taking audio notes with my phone recorder app. I then have a make.com workflow in place where I push the audio file through Whisper to get a transcript of it, and start other workflows based on the content of the transcript.

Sometimes it's just a simple note to remind me to do something, which gets added to my TODO list. Sometimes it's an idea about a blogpost I'd like to write, which gets added to my list of blogposts drafts. Sometimes it's the recap of the latest TTRPG game we played, which gets added to our campaign document.

Most of those recordings are only a few minutes long, but the last kind (the TTRPG session recap) can get lengthy, and I've already ended up with files of up to 50MB. Whisper has a limit of 25MB, so I had to come up with a way to decrease the filesize before sending it to Whisper.

Make scenario that downloads an mp3, then either converts it with CloudConvert or transcribes it with OpenAI Whisper

I now have two paths in my make.com scenario. The bottom one is the happy path, when the filesize is below 25MB. The top one is only triggered when the file is too heavy.

In that case, I push the file through CloudConvert, to get a lighter version, and then I go to the second branch with that compressed file instead of the default one.

Compression settings

Whisper doesn't need very high audio quality to create a transcript, so I can compress pretty aggressively and still get a good transcript. What I found to be impactful is to:

  • Format: Convert from mp3 to m4a with the AAC codec. Slightly lower filesize for equal quality.
  • Channels: I set the channels to 1, actually making it a mono rather than stereo file.
  • Audio bitrate: I halved it down from 128 kbps to 64 kbps.
  • Sample rate: I also halved it from 44.1 kHz to 22.05 kHz

In CloudConvert parlance, this means setting conversion and engine specific options as:

  • audio_codec set to aac
  • channels set to 1
  • audio_bitrate set to 64
  • sample_bitrate set to 22050

I'm not doing this conversion on every file, only on those that are too heavy. CloudConvert free plan allows for up to 10 conversions per day, which is enough for my needs. The conversion takes a few minutes to run, and again, that's fine for my current needs.

The final quality of the audio is clearly inferior to the original, but this doesn't seem to bother Whisper at all, which can still create a good transcript out of it.

Hope that help other people get over the 25MB limit of Whisper.