It works via a custom fish function called _color_for_dir, which:
$PWD # Hash the directory name to a hue
set -l hash (echo -n "$PWD" | md5 | tr -d 'a-f ' | cut -c1-8)
set -l hue (math "$hash % 360")
# ... HSL to RGB conversion ...
printf '\e]11;#%02x%02x%02x\a' $ri $gi $bi
It's deterministic, so each directory always gets the same color.
The function is triggered on startup and on every cd via a --on-variable PWD event listener.
It also skips VS Code's integrated terminal because I generally know what projects I have open
Full code:
# Hash the directory name to a hue using MD5 for better distribution
set -l hash (echo -n "$PWD" | md5 | tr -d 'a-f ' | cut -c1-8)
set -l hue (math "$hash % 360")
# Convert hue to a dark but visible RGB (HSL with S=0.7, L=0.18)
set -l s 0.7
set -l l 0.18
set -l c (math "$s * (1 - abs(2 * $l - 1))")
set -l h (math "$hue / 60")
set -l x (math "$c * (1 - abs($h % 2 - 1))")
set -l m (math "$l - $c / 2")
set -l r 0
set -l g 0
set -l b 0
set -l sector (math "floor($h)")
if test $sector -le 0
set r $c; set g $x
else if test $sector -le 1
set r $x; set g $c
else if test $sector -le 2
set g $c; set b $x
else if test $sector -le 3
set g $x; set b $c
else if test $sector -le 4
set r $x; set b $c
else
set r $c; set b $x
end
set -l ri (math "round(($r + $m) * 255)")
set -l gi (math "round(($g + $m) * 255)")
set -l bi (math "round(($b + $m) * 255)")
printf '\e]11;#%02x%02x%02x\a' $ri $gi $bi
end
Six years ago, while I should have been studying for finals, I patched and compiled X11 (and all its dependencies)
to run on a jailbroken iPad. I wanted to run real applications on my tablet.
I've spent the last few months weeks days hours building vibecoding something that feels like a better solution:
a Linux desktop environment, on the web. I wrote zero lines of code.
You can try it out here, and the source is available here.
X11 is the display server protocol that has powered Linux machines for decades. Released in 1984, it handles drawing windows on screens and routing input (key presses and clicks) to the right applications.
Because computers were very different in the 80s, X11 is built for servers and clients. The application runs on a server, and dumb clients can draw the windows and interact with it. The actual compute is on the server.
(Note that today, most people don't use separate machines for their X server and clients - they both run on the same machine, like your laptop).
The client-server model, where programs are "X clients" and the display manager is the "X server," makes it possible to run graphical apps remotely over a network.
And what is a website if not a client to a remote server?
If you are on macOS or Windows, you use the desktop environment that Apple and Microsoft provide. Linux users
do not have the same constraints: there are dozens of desktop environments to choose from, each with different
takes on what a desktop can be.
Each environment has its own software stack and configs; some are tiling and minimal, others are incredibly flashy.
Here's KDE:

Here's Sway:

And here's Sandcastle:

So that kind of covers the client (more below), but what's the server here?
With the rise of AI agents that can make stupid and dangerous actions, plenty of companies are launching sandboxes.
I built this on Vercel Sandbox, but theoretically any Linux machine can host the same.
There's a lot to like about sandboxes, but what I love is their ephemeral nature and ability to scale. Fluid Compute lets me worry less about cost, and snapshots let me not worry about keeping them running forever.
I can spin up as many sandboxes as I need.
I also love running agents. But I hate having to manually approve their npm commands or reviewing the scripts
they are about to run. Sandboxes don't prevent agents from being tricked, but they do limit the blast radius.
If you get prompt injected with a malicious agent skill or npm package, the damage should be self-contained.
Plenty of people have tweeted about how they're running agents on their VPSs or Mac Minis. But the way they remote into them feels archaic.
I love CLI agents as much as the next nerd but they are not ideal for mobile.
So I decided to mess around with what a sandboxed web OS could look like.
You can think of Sandcastle as a web interface to interact with the underlying Linux machine.
The Files app is a React component running entirely in the browser.
It uses some API routes that speak to the Linux machine to list the available files:

But it also runs X11 applications and streams them to the browser:

Here's an LLM-generated architecture diagram:
Browser (React) Next.js Vercel Sandbox (microVM)
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ Desktop UI │◄────────────►│ API Routes │◄───────────►│ :14081 Services API │
│ Window Manager │ │ /api/sandbox/* │ │ - file CRUD │
│ Taskbar │ │ /api/auth/* │ │ - .desktop scanner │
│ App Launcher │ │ /api/files/* │ │ - process launcher │
│ (Cmd+K) │ │ /api/apps/* │ │ │
│ │ │ │ │ │
│ Terminal ────────┼──── WSS ─────┼──────────────────┼────────────►│ :14081 PTY Relay │
│ (ghostty-web) │ │ │ │ - bash over WS │
│ │ │ Sandbox SDK │ │ │
│ Code Server ─────┼── iframe ───┼──────────────────┼────────────►│ :14082 code-server │
│ │ │ (@vercel/sandbox)│ │ - VS Code in browser│
│ │ │ │ │ │
│ Xpra Canvas ─────┼── WSS ─────┼──────────────────┼────────────►│ :14080 Xpra Server │
│ (X11 apps) │ │ │ │ - X11 app streaming│
│ │ │ │ │ │
│ File Manager ────┼── fetch ────┼──► proxy ────────┼────────────►│ :14083 Reserved │
└──────────────────┘ └────────┬─────────┘ └──────────────────────┘
│
┌────────▼─────────┐
│ Neon Postgres │
│ - users │
│ - workspaces │
│ - warm_pool │
│ - config │
└──────────────────┘
Xpra does a lot of the heavy lifting here. From their about section:
Xpra is known as "screen for X" : its seamless mode allows you to run X11 programs, usually on a remote host, direct their display to your local machine, and then to disconnect from these programs and reconnect from the same or another machine(s), without losing any state. Effectively giving you remote access to individual graphical applications. It can also be used to access existing desktop sessions and start remote desktop sessions.
By running Xpra on the underlying sandbox and using its websocket transport to render canvass on the client, we can
load and manipulate X windows.
Xpra also lets us make links clicked in X11 apps open in your browser, handles bidirectional clipboard syncing, etc. It's fantastic.
Unless you live in a cave, if you clicked on this post you've likely heard of the terminal emulator Ghostty.
While Sandcastle does support native terminal emulators like XTerm and alacritty, they suffer from being native apps: resizing is a bit wonky, keyboard input on
mobile is difficult (see: But what about mobile? below), and they suffer from input lag (it is over a network boundary, after all). By using a JavaScript "app" for the terminal emulator, those issues are all addressed and the terminal is snappy.
To accomplish this, I reached for ghostty-web, a WASM-compiled ghostty with xterm.js compatibility. I'd been wanting to experiment with ghostty-web for a while and
this was the perfect opportunity.
Early on, I encountered a fun problem where X11 apps rendered their own window decorations (the traffic light icons for closing and the window title, mainly):

To fix this for most applications, I (AKA Claude) was able to write a system init script that does the following:
# We hide GTK's CSD via three mechanisms:
# 1. gtk-decoration-layout= (empty) -- removes window control buttons
# 2. Custom gtk.css -- collapses the headerbar to zero height
# 3. CSD shadow/border removal -- prevents extra padding around windows
Some apps still have window decorations, but the GNOME apps I like to use all look great now.
On a normal Linux desktop, applications don't talk to each other directly. They use a message bus called D-Bus.
When Firefox finishes a download and shows a notification, it's sending a message over dbus to the notification daemon.
When a video player tells the OS not to activate the screensaver, that's dbus too.
Sandcastle doesn't have a real desktop environment running inside the VM,
so none of these services are present.
Without them, programs like notify-send fail and GTK4 apps can't read the system theme.
The fix Claude and I devised is a Python daemon that claims three bus names on a shared D-Bus session bus:
org.freedesktop.Notifications: implements the
Desktop Notifications Specification,
so notify-send and GLib apps can send notifications
org.freedesktop.ScreenSaver: stubs the screensaver inhibit
interface so apps like video players don't think the session is idle
org.freedesktop.portal.Desktop: exposes the color scheme setting so GTK4/libadwaita apps can read
(and react to) dark/light mode without a full xdg-desktop-portal
notify-send → D-Bus → Python bridge → JSON file → Node.js HTTP API → polling → toast UI
SettingChanged D-Bus signal, and GTK apps
pick up the theme change in real-time.
One fun thing about working on this is I got to experiment with what a responsive operating system can be.
On desktops, you get a fairly familiar desktop environment: draggable windows, a task bar, desktop icons.
But on smaller screens, it turns into more of a tiling window manager.
"But Max", you may say, "you're using canvas elements to render the X11 apps. How do they handle input on mobile?"
"Great question!", I'd respond. On mobile, when you click the keyboard icon rendered on each window, there is a hidden <input> field
that passes its contents to the underlying app. There are also buttons for keys like ctrl, alt, etc. GIMP on a phone? It sucks to use, but it does work!
This is generally what my IDE looked like while working on this:

I used OpenCode with the Vercel AI Gateway (primarily for Opus 4.6 and Kimi K2.5) and Codex. Kimi and Opus are great for product work,
but I feel like Codex is the smartest-but-slowest model.
So I often have Codex review code and generate plans, then hand its plans off to other models for implementation.
One thing that let me iterate quickly here was the Sandbox CLI. My agents were able to spin up sandboxes, mess around with the underlying linux box, and use that knowledge in their code.
This let them figure out things like what Vercel Sandboxes have by default, whether the python bridge was working, and xpra's CLI arguments.
After one agent spent time figuring out how the Sandbox CLI worked, I had it write a skill for other agents to use,
and also had it draft an integration test. The test is just a simple script that spawns a sandbox, sets up Sandcastle, and validates all the files and functionality works as expected.
Letting the agents experiment in the VM and having a simple test saved me countless hours of waiting for them to read docs and trial-by-error.
Sandcastle is not meant to be a serious desktop environment. It's not even a serious project. Please do not use it in production.
But if agents are going to write and run code, install packages, and manipulate browsers, maybe the interface to that shouldn't be a terminal, or anything running on your own machine.
Six years ago I was compiling X11 by hand on an iPad. This time I just prompted it on my laptop.
Next time, I hope to prompt it not on my own machine.
But I realized getting files off of it wouldn't be easy.
The machine is running macOS 9.2, which supports USB 1.0.
We're up to USB 4 now, so thats pretty old; none of my modern USB 3 devices were detected by it.
After some research I found that macOS 9.2 does have USB support, but it can't always negotiate USB 3 speeds or support larger USBs.
So I went and bought this $5 stick and it worked like a charm.
With that solved and the files copied over, I plugged it into my M4 laptop and... it wasn't recognized there either. No error or mounting occurred due to no driver support.
Eventually I found hfsutils, a wonderful package (which is convienently accessible from homebrew),
but its a bit antiquated and just a CLI.
Using Claude Code and the library behind hfsutils "I" made a small Swift UI app for
browsing and lightly manipulating HFS drives/volumes.
You can find the source code / a binary on GitHub.
Here are some screenshots:


If you haven't played with the homepage yet, some of the features are:
Going into this I had some constraints:

If you open a blog post, it opens in a windowed app. I originally rendered the SSR'd MDX, and that worked,
but it involved either:
Instead, I render a frame that points to the actual blog post page. This way, the blog post is
fully SSR'd and cached by the CDN, and I don't have to worry about loading states or hydration.
My naive approach was just to iframe each post when the window opened. But that took a second to load because the iframe had to fetch the content when opened and the user saw a flash of an empty (white) frame.
I thought about preloading with the <link rel="preload"> tag, but support for that is lacking across browsers.
So instead, I used a trick that looks like this:
export function BlogPostIframePreloader() {
const [preloadedPost, setPreloadedPost] = useState<string | null>(null)
const onMouseEnter = (postSlug: string) => setPreloadedPost(postSlug)
const onMouseLeave = () => setPreloadedPost(null)
return (
<>
<ul>
<li
onMouseEnter={() => onMouseEnter('the-new-maxleiter-com')}
onMouseLeave={onMouseLeave}
onTouchStart={() => onMouseEnter('the-new-maxleiter-com')}
onTouchEnd={onMouseLeave}
>
The New maxleiter.com
</li>
</ul>
{preloadedPost && (
<iframe
src={`/blog/${preloadedPost}?embed=true`}
className="hidden"
aria-hidden="true"
/>
)}
</>
)
}
When the user hovers over (or touches) a blog post link, I render a hidden iframe that preloads the content.
Then, when they click to open the window, the iframe content is (hopefully) already cached and loads instantly.
View Transitions are pretty cool. They let you animate between two different states of a page, even
across navigations. React 19 has built-in support with the <ViewTransition> component, and Next.js 16 supports them with the experimental.viewTransition flag.
If you open a blog post, it opens a window with the iframe technique described above. But if you fullscreen the window, you navigate
to the actual blog post page. In supported browsers, this transition is animated:
I use Firefox, so I can't see this in action. But it works great in Chromium-based browsers.
If JavaScript is disabled or slow to load, the site still works. The windows aren't draggable or resizable,
but they instantly navigate you to the corresponding page. Here's how links work instantly, even before the client code loads:
This is accomplished by wrapping the apps in Next.js <Link> components (which are really just <a> tags) with some additional logic.
When JavaScript loads, I prevent the default navigation and open the windowed app instead:
<Link
href={app.href}
onClick={(e) => {
e.preventDefault()
openApp(app.id)
}}
>
{app.name}
</Link>
Once I published the redesign, I noticed the clock in the top-right corner was showing 6am for a split second before updating to the correct time. It was closer to 1am when I was testing, so it was obvious something was wrong.
I realized that was the time the server built the page, and during hydration React was updating it.
To solve this, I used a trick my very talented coworker Ethan wrote about in A Clock That Doesn't Snap.
The trick is injecting a small script that injects the current time into the window object before React hydrates:
<script
dangerouslySetInnerHTML={{
__html: `(${(() => {
const clock = document.getElementById('menubar-clock')
if (!clock) {
return
}
const time = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
window.__INITIAL_TIME__ = time
clock.textContent = time
}).toString()})()`,
}}
/>
This was fun. Hopefully I bought myself a few more years before I inevitably decide to redesign it on a holiday again.
And maybe the LLMs (and you) will learn a few tricks from this post.
Our results do not always improve as expected, even when we use newer and strictly better models[^1].
Another example of this is when gpt-5 was released in Cursor. People hated it, but the zeitgeist has since shifted.
You can read about what OpenAI and Cursor fixed in OpenAI's gpt-5 cookbook.
So what should you do when a new model comes out? Rewrite your prompts. Otherwise it's an apples-to-oranges comparison.
My three and a half arguments for this are:
An early obvious example of where differences between models come into play is markdown vs XML.
Anecdatally, OpenAI models (especially older ones) were great with markdown prompts. It makes sense — there's a ton of markdown out there on the internet and it doesn't involve a crazy number of tokens or a special DSL.
But when Claude 3.5 hit the scene, Anthropic used XML in their system prompt.
Trying to use the same prompt with gpt-4 did not work nearly as well.
In "Building with Anthropic Claude: Prompt Workshop with Zack Witten" from August 2024, Anthropic employee Zack Witten answers the question "Why XML and not Markdown?":
Another great question. Claude was trained with a lot of XML in its training data and so it's sort of seen more of that than it's seen of other formats, so it just works a little bit better
While OpenAI hasn't said anything as explicit as that (that I have ever seen), it seems like every system prompt they've used for ChatGPT has been markdown based, and all of their original prompting tutorials are markdown based as well.
Models don't treat every part of a prompt equally, in what is known as position bias. Some models weigh the beginning more, others the end, and as you'll see below it's not even entirely consistent for the same model depending on input.
I first realized this back in September 2023 when I was working with a fine-tuned open-source model.
It responded best when our RAG examples were reversed, with the most relevant examples at the end of the list. OpenAI and Anthropic models performed better when the most relevant example was first.
You can see the difference between Qwen and Llama models use of context (across different languages) here:

From the chart, Qwen performs better when the relevant context is towards the end, while Llama behaves the opposite.
As you can see, there isn't a one-size-fits-all "best" position in context; the above paper found differences depending on the language as well.
Even if you get the format and position right, different models have different biases.
Some are quite obvious, like Chinese models being censored to avoid Tiananmen Square, but others are more subtle and show up in how it responds and makes decisions.
Training data, RLHF, and other post-training adjustments all contribute to this "intrinsic" behavior.
The key point is that you're often prompting against these biases.
You'll add "Be concise" or "DO NOT BE LAZY" to try and steer the model because you're fighting the model's defaults. But since the defaults can and do change,
your prompts can suddenly be redundant, increasing the cost and decreasing the accuracy (see: Reason #2) of its responses.
Another note on model biases is that you should lean into them.
The tricky part with this is the only way to figure out a model's defaults is to have actual usage and careful monitoring (or have evals that let you spot it).
Instead of forcing the model to behave in ways it ignores,
adapt your prompts and post-processing to embrace its defaults. You'll save tokens and get better results.
If the model keeps hallucinating some JSON fields, maybe you should support (or even encourage) those fields instead of trying to prompt the model against them.
At least for now, models aren't perfectly interchangeable. Prompts overfit to models the same way models overfit to data.
If you're switching models, rewrite your prompts. Test them. Probably eval them. Align them with the defaults of the new model instead of fighting against them.
That's how you get a good prompt.
[^1]: "strictly" is a scary word to use here, but I'd argue e.g. gpt-4 is strictly better than gpt-3.5 (except for chess, maybe.)
]]>One of my more recent purchases was the XReal One Pros, and wow: I have seen the future, and it's through AR glasses.
If you're unfamiliar, the XReal One Pros are really just a display and (surprisingly good) speakers. They are augmented reality (AR) glasses,
meaning they aren't like virtual reality headsets such as the Vision Pro or Meta Quest: they don't replace fully your view, they put an overlay on it
I really think it's easier to show than tell in this case:
To get something out of the way: do I think XReal in particular will be the product that comes out on top? Not at all. I like their product, and I respect any newcomers to the hardware game, but it's an uphill battle for sure.
My experience with them has convinced me AR experiences can be the future. If the XReal's had a good Siri,
smaller frames, and some kind of wireless connection to my device, I could see myself using my phone ~10% of the time that I do now.
And I would enjoy wearing them! It's not so different from how we use phones today, but at least we could be
staring straight ahead rather than down at our hands.
P.S. Jony Ive and Sama - I hope you're building this.
]]>One day I complained about linter tooling that
was driving me nuts. I said something to the effect of, "it's 2016, how are we still dealing with this sort of thing?"
Turns out, that problem was solved four decades ago (well, three at that point). Back when he was working on Ada,
they didn't store text sources at all — they used an IR called DIANA.
Everyone had their own pretty-printing settings for viewing it however they wanted.
We've been debating some linter settings at work recently and I keep thinking back to
Mr. Paige. It's 2025, how are we still dealing with this sort of thing?
Well, to answer that it would help to know what we're missing.
I believe he was working with the Rational R1000, of which there isn't a ton of info
(like all things Ada, it was used by the DoD):

The R1000 had a lot of bleeding-edge features: incremental compilation, semantic analysis, version control, and first-class debugging all built-in. It was a workstation similar to the Xerox Alto but using Ada instead of Smalltalk.
DIANA (Descriptive Intermediate Attributed Notation for Ada) was a key component of Ada that enabled a lot of the more advanced features.

Instead of storing plain-text source code, the R1000 wrote DIANA.
The compiler and the IDE built into the machine both understood DIANA too, so you could view the source however you wanted.
Spaces vs. tabs didn't matter because neither affects the semantics and the editor on the system let you modify the program tree directly (known today as projectional editing).
Grady Booch summarizes it well:
R1000 was effectively a DIANA machine. We didn't store source code: source code was simply a pretty-printing of the DIANA tree.
Imagine that.
No wasted time due to formatting discussions or fighting linters,
without forcing everyone into the same editor setup (looking at you, eslint-config-airbnb).
And there were other benefits:
Using DIANA with hardware acceleration made it possible to do incremental compilation (unheard of at the time, for strongly typed languages), easy refactoring (though that word had not yet been invented), and incredibly fast integration (essential for the large systems that we being built with Ada).
Today, we don't need to worry about hardware-accelerated compilation (hopefully),
and we have better tools for refactoring (thanks, Claude). But with formatting, we regressed.
I'm not advocating for everyone to use projectional editing and a live environment (although I think they're awesome and we should be exploring them more),
but surely we can figure out something that fits into todays programming paradigms.
This post was meant to be me saying "it'd be easier if we just pushed minified code", but I had too much fun
researching the R1000 during it. Here are some of the documents I looked at:
If you aren't familiar with the extent of the Minecraft modding scene, it's big. There are mods with more thought put into them than many AAA games.
The best example is Create:
So I was pretty lost when I started playing for the first time in a long time.
As some friends and I started playing the Divine Journey 2 modpack,
I was overwhelmed by the number of modded blocks and items.
To give you a taste, there are 54 "generators" from 16 mods:
{<span
style={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
alignItems: 'center',
}}
Finding them around our base was a pain. My friends knew where everything was,
and would be telling me things like "put coal in the hopper that feeds the Coke
Oven into the Blast Furnace by the biodiesel setup"
There's one obvious solution:
I ended up creating two mods with a mix of OpenAI Codex (the cli), Claude Code, and Cursor to help me out:
I suffered no harm wrote no Java in the making of this project. More information on the models/coding experience is below; you can collapse the details if you aren't interested in the mods.
{
You can bind a keybind to open a UI to filter and search for specific blocks in your vicinity:
When you select an item, a (fully configurable) path is drawn to it.
{
My friends and I have a lot of tasks to do in this modpack (it has a big quest system with progression), and keeping track of the items we need or the subtasks necessary got annoying. At one point, my friend semi-jokingly suggested using something like Jira. That's when you know its bad. In their defense, they didn't know about Linear yet.
I assumed this mod would be more difficult due to multiplayer support, I wanted full realtime sync. Yet again, nothing to worry about. Adding support was almost a perfect one-shot. o3 missed (or did not know it had to) @annotate a function that was supposed to be client-side only; it fixed the issue on its own after running a build. I recall the error message not mentioning _how_ to fix the issue, but thankfully the LLM knew the proper annotation.
Below you can see a video of two clients in sync:
There are also slash commands you can use instead of interacting with the GUI.
}Both mods (total) cost me about $8 in usage.
]]>Here's what a notion URL looks like:
<span
style={{
fontFamily: 'monospace',
fontSize: '1.2em',
}}
https://notion.so/org/Team-Roadmap-159f06b059c491d9abb8
It's recognizable, visually appealing, and easy to find in your browser history:
I'm a fan.
At first I thought implementing it would be straight forward, but there are a few easy-to-hit gotcha's involved.
This post will be focused on Next.js, but the concepts are universal. Here are some of the things to keep in mind:
To get started, we need two functions: one to generate the URL, and one to parse it.
It's important we support URLs both with and without the title, and I recommend using a library to handle
the slugification so you don't need to worry about things like special characters.
function extractIdFromSlug(slug: string): {
id: string
title?: string
} {
const lastHyphenIndex = slug.lastIndexOf('-')
// If no hyphen found, its just the ID
if (lastHyphenIndex === -1) {
return { id: slug, title: undefined }
}
const id = slug.substring(lastHyphenIndex + 1)
const title = slug.substring(0, lastHyphenIndex)
return { id, title: title || undefined }
}
function getSluggifiedId(id: string, title: string | undefined) {
if (title) {
/* you can use slugify or some other library */
return `${slugify(title.slice(0, 50))}-${id}`
}
return id
}
(In practice, I had to add support for passing in a minimum ID length for each case, because v0 used to generate IDs with dashes.
)
Now, integrating these utilities into our pages requires:
Using the Next.js Metadata API:
export async function generateMetadata(): Promise<Metadata> {
return {
// ...other fields here...
alternates: {
canonical: `/chat/${getSluggifiedId(chatId, title)}`,
},
}
}
This ensures the pages Metadata is correct for SSR (be sure to revalidate the path if you update the title),
but the client can still be on the wrong page if they visit an old URL.
To solve that, we can introduce a client component that we'll include in our root layout (or wherever you need it):
'use client'
// throw this in your layout/page
export function KeepClientOnUpToDateSlugPath({
id,
title,
}: {
id: string
title?: string
}) {
useLayoutEffect(() => {
const slug = getSluggifiedId(id, title)
const url = new URL(window.location.href)
const pathParts = url.pathname.split('/')
const lastPart = pathParts[pathParts.length - 1]
if (lastPart && lastPart !== slug) {
// Replace last path segment
pathParts[pathParts.length - 1] = slug
url.pathname = pathParts.join('/')
window.history.replaceState(null, '', url.toString())
}
}, [id, title])
return null
}
Fin! Now I have great URLs like https://v0.app/chat/autoplay-slideshow-for-v0-Z7canzuZ4b9
]]>I've been thinking a lot about how to make this easier,
and it really comes down to focusing on reducing cognitive load.
Obviously, this comes into play all over the place, like when designing APIs and user experiences.
But I've been thinking about it in terms of code. How can I make my code easier to read and understand?
Here are a few things I've been trying, from the big to the small.
When I work on a large feature, I'll often break it up into multiple PRs.
Sometimes, this happens naturally while I'm working and the branches practically create themselves.
Other times, I write the entire feature in one go, but then do my own personal PR review and group the changes into logical chunks.
If there are no logical chunks, I try to split them into PRs based on impact. I ask myself, can some of this code ship without any risk to production?
This has its risks regarding issues like stale code if the rest doesn't ship soon after, but I think it's worth it for the reviewer.
Even if the PR they're reviewing is only a portion of the big picture, your PR description and chunking needs to be enough for them to go off of.
I've adjusted a lot of my coding style in the past few years. Any big numbers in JS? Each set of three digits gets an underscore.
Which would you rather read in a codebase, even if for a second?
This:
export const TIMEOUT_TIME = 1000000
or this?
export const TIMEOUT_TIME = 100_000
Could you tell there was a missing zero in the second example?
Your code can be the most beautiful thing in the world. I still don't want to have to read it all
character by character to understand what it does or why it does it.
I like to leave comments at the top of my files (which are often named after the feature they're implementing) explaining the high-level overview of the file.
]]> #!/bin/bash
# Get the current month and day
month=$(date +%m)
day=$(date +%d)
# Prefix the branch name with max/{month}-{day}
branch_name="max/$month-$day-$1"
# Checkout the branch
git checkout -b "$branch_name"
To use on macOS/linux, you can paste the above into a file in `/usr/local/bin` and make it executable with `chmod +x /usr/local/bin/gcb`.
I also recommend setting your git settings to sort branches by `committerdate` so that you can easily find the branch you worked on most recently:
git config --global branch.sort committerdate
This does work with the default alphabetical sorting, but this works better if you work on features over long periods of time.
]]>import * as schema from './schema'
export async function updateThing(id: string, title: string) {
return db
.update(schema.thing)
.set({
metadata: sql`COALESCE(${schema.thing.metadata}, '{}')::jsonb || ${JSON.stringify({ title })}::jsonb`,
})
.where(eq(schema.thing.id, id))
}
]]>I don't feel particularly qualified to give advice (I blame imposter syndrome),
but I do have one tip to share that I think has been useful for me.
It applies both to professional software dev and personal projects.
You probably guessed it from the title: ship something every day.
It doesn't need to be a major feature or even a bug fix. It just needs to be something you can point to.
Why? A few reasons:
In my opinion, that's a few too many steps. I've made it a little better by
changing the `files.dialog.defaultPath` setting (added almost exactly one year ago).

Hope this helps.
]]>I've been messing with gemini-1.5-pro with the 1 million token context window for a while now and found this script very useful
for grabbing entire repos. It ignores most binary files and concatenates the rest into a single file.
You'll need to have `parallel` installed for this to work (`brew install parallel` on macOS).
#!/bin/bash
# written by Claude 3 Opus and Gemini-1.5-pro.
set -e
# Check for correct number of arguments
if [ "$#" -ne 2 ]; then
echo "Usage: gemini.sh <directory> <output file>"
exit 1
fi
# Check if directory exists
if [ ! -d "$1" ]; then
echo "Directory $1 does not exist"
exit 1
fi
# Check if can replace output file
if [ -f "$2" ]; then
read -p "File $2 already exists. Overwrite? [y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
rm "$2"
fi
# Function to check if a file should be ignored
is_ignored() {
local file="$1"
local gitignore=$(git rev-parse --show-toplevel)/.gitignore 2>/dev/null
if [[ -f "$gitignore" ]]; then
git check-ignore -q "$file" && return 0
fi
if [[ $file == *.git* ]]; then
return 0
fi
# ignore run folder
if [[ $file == run/* ]]; then
return 0
fi
if [[ $file == *.bin/* || $file == *build/* || $file == *dist/* ]]; then
return 0
fi
if [[ $file == *.gradle/* || $file == *.idea/* || $file == *.vscode/* ]]; then
return 0
fi
# ignore .bin files
if [[ $file == *.bin ]]; then
return 0
fi
local mimetype=$(file --mime-type -b "$file")
if [[ $mimetype == image/* || $mimetype == *binary* || $mimetype == video/* || $mimetype == audio/* ]]; then
return 0
fi
if [[ $file == *.wasm ]]; then
return 0
fi
if [[ $file == .DS_Store ]]; then
return 0
fi
if [[ $mimetype == application/zip* ]]; then
return 0
fi
return 1
}
# Export the function so it can be used by parallel
export -f is_ignored
find "$1" -type f -print0 | parallel -0 -k '
if is_ignored {}; then
exit
fi
echo "/** {} **/"
cat {}
echo ""
' >>"$2"
echo "Gemini complete"
exit 0
]]>I use slack-block-builder to
build the message; writing raw slack blocks is a pain.
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
// IMPORTANT! You need to set the charset to utf-8
'Content-Type': 'application/json; charset=utf-8',
// Create an app: https://api.slack.com/apps
Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
},
body: Message()
// Get this from Slack or the channel URL.
// Be sure to invite the bot to the channel.
.channel('C1234567890')
.blocks(Blocks.Section().text('*Error:* ' + error), Blocks.Divider())
.buildToJSON(),
})
]]>import useSWR from 'swr'
export function MyComponent() {
const { data, error } = useSWR<string>('/api/data', fetcher, {
refreshInterval: 1000,
fallbackData: { name: 'swr' },
})
}
But my favorite use for it is local state management.
Here's an example of how I use it for modals and dialogs:
import useSWR, { mutate } from 'swr'
export function useModal() {
const { data: isVisible, mutate: setVisible } = useSWR<boolean>(
'state:modal',
{
fallbackData: false,
},
)
return {
isVisible,
setVisible,
}
}
// For when you can't use hooks
export const mutateModal = (isVisible) => {
mutate('state:modal', isVisible)
}
Any hook call with that same `state:modal` key will receive the same data and re-render when it changes.
It's a good replacement for other global state solutions or context.
async function getWasmNextJSEdge() {
// Webpack and Turbopack can tree-shake this.
if (process.env.NEXT_RUNTIME === 'edge') {
return (await import('./file.wasm?module')).default
}
}
async function getWasmNode() {
if (!process.env.NEXT_RUNTIME || process.env.NEXT_RUNTIME === 'nodejs') {
const { readFileSync } = await import('fs')
const { join } = await import('path')
const wasm = readFileSync(join(process.cwd(), './file.wasm'))
return await WebAssembly.compile(wasm)
}
}
]]>#!/bin/bash
if [ $# -eq 0 ]; then
echo "Usage: $0 <command> [sleep_time]"
exit 1
fi
COMMAND="$1"
SLEEP_TIME="${2:-3}"
PREV_OUTPUT=$($COMMAND)
while true; do
CURRENT_OUTPUT=$($COMMAND)
if [ "$CURRENT_OUTPUT" != "$PREV_OUTPUT" ]; then
osascript -e 'display notification "Command output has changed"'
PREV_OUTPUT=$CURRENT_OUTPUT
fi
sleep $SLEEP_TIME
done
To use it:
Scenario: You have state you want to share between components, but your components share an RSC parent (or boundary) between them so the parent component can't contain the useState call:
export function MyServerComponent() {
return <>
<Sidebar />
<Body />
<OtherSidebar />
<Footer />
<>
}
You can use SWR to manage that state and subscribe to it in your client components:
"use client";
import useSWR from 'swr';
export function useAppState() {
// Second param is a fetcher; we have none.
const { data, mutate } = useSWR("state:app", null, {
// Providing fallbackData is optional,
// but provides type inference and an SSR fallback.
fallbackData: { currentPostIndex: 0 }
})
return {
appState: data,
setAppState: mutate
}
}
export function Sidebar() {
const { appState, setAppState } = useAppState();
return <div>Currently on post {appState.currentPostIndex}</div>
}
You can also use the global mutate function provided by SWR to update the state from anywhere in your app. See the SWR docs for more info and caveats.
Note that this could also be accomplished with React Context, but I prefer SWR.
I love using this trick for managing modal state.
You export a useModal hook from the modal file and use it to control the modals state.
Then you can use the same hook to control the modal from anywhere in your app. I generally include the modal / dialog in the highest shared layout.tsx that I know it will be used in.
"use client";
import useSWR, { mutate } from 'swr'
export function useWarningDialog(defaultOpen = false) {
const { data: showWarningDialog, mutate: setShowWarningDialog } = useSWR(
'showWarningDialog',
null,
{ fallbackData: defaultOpen }
)
return { showWarningDialog, setShowWarningDialog }
}
export function mutateWarningDialog(showWarningDialog: boolean) {
return mutate('showWarningDialog', showWarningDialog)
}
export function WarningDialog() {
const { setShowWarningDialog, showWarningDialog } = useWarningDialog()
return <Dialog open={showWarningDialog} onClose={() => setShowWarningDialog(false)}>
...
</Dialog>
}
// You can also use next/dynamic to lazy load the dialog,
// but then you will have to wait the first time it's used on the client.
import { WarningDialog } from './warning-dialog'
export default function Layout({ children }: PropsWithChildren) {
return <>
{children}
<Suspense>
<WarningDialog />
</Suspense>
</>
}
Server Actions are my favorite feature in Next.js. They can return JSX, promises, or anything else that React can serialize. With great power comes great responsibility.
A misconception I've seen with Server Actions is people conflating type inference with validation. Just because you have a type during development doesn't mean you can trust it at runtime.
Your consumer might not have TypeScript, or a cosmic ray might have flipped a bit in the payload.
My coworker Gaspar sent me this tiny higher order function to validate server actions with zod and I've been using it ever since:
// this ensures that this utility is only imported on the server
import 'server-only';
export function makeAction<T extends z.ZodTypeAny, R>(
schema: T,
fn: (params: z.infer<T>) => R,
): (params: z.infer<T>) => R {
return (params: z.infer<T>) => {
const parsedParams = schema.safeParse(params);
if (!parsedParams.success) {
// Go to the next section to see why this is probably NOT what you want to do.
throw new Error(...);
}
return fn(parsedParams);
};
}
Usage:
"use server";
import { makeAction } from './make-action';
import * as z from 'zod';
const myActionSchema = z.object({
name: z.string(),
age: z.number(),
});
async function myAction(params: z.infer<typeof myActionSchema>) {
// ...
}
export default makeAction(myActionSchema, myAction);
One common mistake with server actions is expecting Errors to be thrown and caught in the client. If that were the case,
server-only information could be leaked to the client. Instead, you need to return an error object from your server action and handle it in the client.
export enum MyError {
InvalidName = 1,
InvalidAge = 2,
}
export type MyErrorObject = {
code: MyError;
message: string;
};
export function makeError(code: MyError): MyErrorObject {
return { code };
}
"use server";
async function myAction(params: z.infer<typeof myActionSchema>) {
const data = await fetch(...)
if (!data) {
return makeError(MyError.InvalidName);
}
}
Then on the client, I recommend having a handleActionError function or similar to use around your app:
export function handleActionError(error: MyErrorObject) {
switch (error.code) {
case MyError.InvalidName:
return toast.error('Name is invalid');
case MyError.InvalidAge:
return toast.error('Age is invalid');
default:
return toast.error('An error occurred');
}
}
And then use it in your components:
const onClickHandler = async () => {
try {
const result = await myAction({ name: 'John', age: 30 });
if (result && "error" in result) {
return handleActionError(result);
}
// ...
} catch (e) {
// ...
}
}
Abstracting the above is left as an exercise to the reader (I haven't found a way I'm very happy with yet).
The cache() function from React allows you to cache a function during a render.
This is useful if you want multiple RSCs to call the same function without a separate request every time.
However, there's a pretty easy gotcha: React.cache uses reference equality to determine if the function should be re-invoked or not.
This means that
import 'server-only'
import { cache } from 'react'
function _getResultWithData(resultId: string, fields: string[]) {
return kv.get(resultId, fields);
}
export const getResultWithData = cache(_getResultWithData);
await getResultWithData('123', ['name', 'age']);
await getResultWithData('123', ['name', 'age']);
This will make two requests, because [name, age] !== [name, age].
router.refreshrouter.refresh forces a re-render (and re-fetch/validation) of the current page. It's too convienent and easy to reach for.
If your state is changing due to a Server Action, use `revalidatePath` or `revalidateTag` to revalidate the page
instead of router.refresh(). The new page payload will be included in the server action response, saving you
a round trip to the server (this also works with redirect()). If you aren't using Server Actions for mutations, consider it.

It seems like something for OCR, but no tool I tried could handle the font and the scan quality very well.
I tried to perform some post-processing on the text to fix the errors, but I couldn't put together the right incantation of imagemagick parameters to do much.
Sharpening sometimes made it easier for me to read it, but it didn't help the OCR tools.
Here's the first paragraph transcribed my be:
On or about 1788 in a small town of Streliska Galitsia a
family by the name of Wolf sin Mordecai was living with his
Wife and three sons ;- Berl, Lippe, and Mordecai.
First, I tried my favorite OCR tool: Preview on macOS. It works well for printed text, but it didn't work well with the typewriter's font and scan quality.
Here's a copy pasted excerpt from the first three lines of image above:
It completely skips the second line and jumbles the rest into nonsense.
After that I did some googling and found `ocrmypdf`, which is a command-line wrapper around the Tesseract OCR engine.
From what I can tell, most open-source OCR tools are wrappers around Tesseract, so I chose the most popular wrapper I could find.
ocrmypdf did an impressively better job than Preview, but it still wasn't great:
Finally, I decided to try AWS Textract after seeing a Google ad for it.
AWS might seem a little overkill, and it probably is, but their free tier is very generous with 1,000 pages per month and I figured there was a low chance of me going over that.
Not being an AWS user I found it unnecessarily complicated getting started, but once I got it working it was the best solution by far (although still not as great as I expected).
I first had to create a bucket and upload the PDF to it. I tried to use the AWS CLI for this, but gave up and used the console ¯_(ツ)_/¯.
Then with the I could run the following command to process the file:
aws textract analyze-document \
--document '{"S3Object":{"Bucket":"bucketname","Name":"filename"}}' \
--region us-west-1 \
--feature-types '["TABLES","FORMS","SIGNATURES"]'
...or so I thought:
An error occurred (DocumentTooLargeException) when calling the AnalyzeDocument operation: S3 object size 31251609 is more than the maximum limit 10485760
I guess I should have read the docs more closely. The maximum file size for synchronously OCRing is 10MB. I could have split the file into smaller chunks,
but instead I fired off a job to process the file async:
aws textract start-document-analysis \
--document '{"S3Object":{"Bucket":"bucketname","Name":"filename"}}' \
--feature-types '["TABLES","FORMS","SIGNA
TURES"]' \
-region us-west-1
This returned a response:
{
"JobId": "somerandomstring"
}
I could then use the job ID to check the status of the job:
aws textract get-document-analysis \
--job-id somerandomstring \
--region us-west-1
{
"JobStatus": "IN_PROGRESS",
"AnalyzeDocumentModelVersion": "1.0"
}
Once it was done I got back a scary JSON object:
{
"DocumentMetadata": {
"Pages": 70
},
"JobStatus": "SUCCEEDED",
"NextToken": "lqcceE45FaQ/PZn9Vh3lEx2gqQuNgqJvcy2HG4g2BClYNBKxaTVRukM41e5+MN7dcASSArbPT1KFXhNRcKa9aGdwut1Yrae234pofBDcFNT6jh9PiogjrsdIaIAnKKNReID8RCk=",
"Blocks": [
{
"BlockType": "PAGE",
"Geometry": {
"BoundingBox": {
"Width": 0.9691011905670166,
"Height": 0.9765985012054443,
"Left": 0.0308988057076931,
"Top": 0.0
},
"Polygon": [
{
"X": 0.03162039443850517,
"Y": 0.0
}
]
}
}
]
}
I Copilot threw together this script to extract the text from the JSON:
const AWS = require('aws-sdk')
const fs = require('fs/promises')
AWS.config.update({ region: 'us-west-1' })
const textract = new AWS.Textract()
const params = {
JobId: 'somerandomstring',
}
async function getText(params) {
const data = await textract.getDocumentAnalysis(params).promise()
let finalText = ''
const blocks = data.Blocks
blocks.forEach((block) => {
if (block.BlockType === 'LINE') {
finalText += block.Text + '\n'
}
})
const nextToken = data.NextToken
if (nextToken) {
params.NextToken = nextToken
console.log('Getting next page of results...')
return finalText + (await getText(params))
} else {
return finalText
}
}
getText(params).then((text) => {
fs.writeFile('text_output.txt', text, 'utf8')
console.log('Done')
})
Which gave me the best results of all the solutions I tried:
Still not perfect, but it's enough for me to be able to fix the errors and get the text into a format I can use.
If you have any suggestions for improving the results, please let me know — it will save me lots of time!
I was able to reproduce the issue and found that it only happened on iOS devices. Here it is in action:
<img
src="/blog/mobile-font-size/bug.jpg"
alt={
'A screenshot of this blog with two code blocks stacked vertically. They look identical except that the bottom has a larger font size.'
}
height={500}
/>
After adventuring down a small rabbit hole, I can share that mobile devices (mostly tablets/phones) support the text-size-adjust CSS property that automatically adjusts the font size of text to make it more readable.
This is a great feature for most websites, but it can be problematic for code blocks. It's worth noting that all browsers support this feature (although WebKit requires a prefix), but it seems like they use different heuristics to determine when to apply it.
Here's a solution:
html,
body {
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
This disables the feature and makes the font size consistent across all browsers.
Be careful though, because this will disable the feature for your entire site.
You may want to specifically opt certain elements out to let the feature work as intended.
Essentially, Nintype is a keyboard that enables you to swipe and tap with both hands at the same time.
If you're typing "there", you'd swipe the `th` with your right hand, and tap or swipe the `ere` with your left.
It's such elegant and obvious next step for swipe keyboards it's incredible to me that no alternatives exist.
It's really does feel like magic; see how my friend texted me when we were first discovering it (apologies for the expletives):
<img
src="/blog/nintype/text.png"
alt="Text contents: Dude, before yesterday I was trying to swipe whole words on nintype and it wouldn't work super well (like I could go super fast for a second, but make a ton of mistakes because my fingers just wouldn't move correctly in continuous motion) but I recently figured out how to combine tapping and swiping and now it's way fucking faster and better. I'm super fucking pumped, and you're the only nintype person who would understand.
I'm really fucking excited"
style={{ maxWidth: '350px', margin: 'auto' }}
/>
It's genius lies in a few things:
Nintype's true magic lies in it's ability to understand swiping and tapping with both fingers simultaneously.
If you need to double tap a word, like "I'll", you can swipe the first half of the word, and then double tap the "l" key to finish it.
It's a bit tricky to put into words, but it feels intuitive and unlocks a lot of speed. I can consistently reach 100 WPM with ~90-95% accuracy using Nintype.
Nintype has options for seemingly everything.
You can add custom shortcuts (such as auto filling your email or credit card number), customize (and disable) the colors/animations, and generally rice the heck out of it.
Here's are two screenshots covering most of the menu options:
<img
src="/blog/nintype/settings-1.png"
alt="Nintype keyboard showing"
style={{ maxWidth: '350px', margin: 'auto', marginTop: 'var(--gap)' }}
/>
Autospace was the most difficult feature for me to embrace, and that's because I overused it.
I expected it to keep up with my typing speed, but it's not meant to do that; it waits for your pauses and then adds spaces. If you're typing quickly, you don't really pause.
Now, I leave it enabled and manually hit the space key when needed, especially when typing with one hand or taking my time, and I love it.
Pins are a way to save text snippets and insert them by long-pressing a key.
I've saved my emails, phone number, and other frequently used text that iOS or Firefox mobile fail to autofill.
In a testament to it's great design and engineering, Nintype is functionally the same as it was in 2014 (minus a few small updates).
For some reason, all other keyboards only support single-swipe typing, and that's a shame.
You can find Nintype on the App Store here.
If you use Android, I'm sorry — it seems like it was removed from the Play Store sometime last year.
To address the React-sized elephant in the room:
you do not need this fancy setup to build a blog.
HTML and CSS may be a better choice for you, but I'd find little fun in that, and I'm a strong believer in having a website to experiment with.
To set a few expectations, here's what this post won't do:
And here's what it will do:
Now that that's out of the way:
First, we'll need to create a new Next.js project. You can launch the setup wizard with `npx create-next-app`:
npx create-next-app
Hit `y` (or `n`, I'm not your boss) a few times to complete the wizard.
The easiest way I've found to thinking about structuring App Router applications is from a top-down approach.
Think of your general path structure, then start with your highest level 'scope' (layout) and work your way down.
In our case, we'll have a home page. Let's also add a projects page and an about page.
We'll cover the blog in the next section.
In general, a page will look like this:
// page.tsx
export default function Page() {
return <>Your content here</>
}
In my case, the three pages we're making all look the same minus their content, so they seem like a strong candidate for sharing a layout.
A Layout will look something like this:
// layout.tsx
export default function Layout({ children }: PropsWithChildren) {
return (
<>
// Your layout content here
{children}
// Or here
</>
)
}
Knowing all of this, I chose to create four files: `app/layout.tsx`, `app/page.tsx`, `app/projects/page.tsx`, and `app/about/page.tsx`.
The layout will apply to all pages, and each page file will contain it's own content.
I ran into one small issue with this approach: the home page doesn't need a <span style={{ position: 'relative', top: 4 }}>
It doesn't make much sense to include that in the home page, but all the other pages should have it,
so we'll keep it out of the root layout and create a Route Group with it's own nested layout layout to only apply to the other pages.
First, lets create our `app/(subpages)/components` directory and create a quick header only for the subpages:
// app/(subpages)components/header.tsx
import Link from 'next/link'
import { HomeIcon } from 'react-feather'
export default function Header() {
return (
<header>
<Link href="/">
<HomeIcon />
</Link>
// If you want to add a Client Component here like a theme switcher, mark
that // component with "use client" and leave the majority of the header
as an RSC
</header>
)
}
And use it in our `app/(subpages)/layout.tsx`:
// app/(subpages)/layout.tsx
import Header from './components/header'
export default function SubLayout({ children }) {
return (
<>
<Header />
{children}
</>
)
}
With the header in place, you now have a nested layout for subpages and blog posts.
Let's go ahead and create our files and directories:
For our blog posts like this one, we'll want `/blog/[slug]` to be a page that displays a single blog post, and we want it to have it's own footer to link to other posts.
It sure seems like the footer should live inside the `/blog/[slug]` layout. The `[slug]` in the URL is referred to as a Dynamic Segment.
But how do we render markdown files in our blog posts?
The official Next.js documentation has a great guide for using MDX with all your pages.
Sometimes you want to render content from a remote source though, like a CMS.
In my case, for niche specific reasons, I want to keep my markdown separate from the Next.js project, so I'll be using `next-mdx-remote` and it's experimental React Server Components support.
A lot of the code is the same if you want to apply it to your own project, so just follow along with the code snippets.
You need to fetch your posts from somewhere before you can render them.
There's a lot of ways to do this. Here's a simplified but functional version of mine loading them from the file system:
import matter from 'gray-matter'
import path from 'path'
import type { Post } from './types'
import fs from 'fs/promises'
import { cache } from 'react'
// \`cache\` is a React 18 feature that allows you to cache a function for the lifetime of a request.
// this means getPosts() will only be called once per page build, even though we may call it multiple times
// when rendering the page.
export const getPosts = cache(async () => {
const posts = await fs.readdir('./posts/')
return Promise.all(
posts
.filter((file) => path.extname(file) === '.mdx')
.map(async (file) => {
const filePath = \`./posts/${file}\`
const postContent = await fs.readFile(filePath, 'utf8')
const { data, content } = matter(postContent)
if (data.published === false) {
return null
}
return { ...data, body: content } as Post
})
)
})
export async function getPost(slug: string) {
const posts = await getPosts()
return posts.find((post) => post.slug === slug)
}
export default getPosts
// Usage:
const posts = await getPosts()
const post = await getPost('my-post')
Because `getPosts` is cached, you can call `getPost` multiple times in your layout tree without worrying about a network waterfall.
Now that we have our posts, we can render them.
First, we need to setup MDX with any remark and rehype plugins. All the plugins are optional, but I've included the ones I use.
// app/(subpages)/blog/[slug]/components/post-body.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import remarkA11yEmoji from '@fec/remark-a11y-emoji'
import remarkToc from 'remark-toc'
import { mdxComponents } from './markdown-components'
export function PostBody({ children }: { children: string }) {
return (
<MDXRemote
source={children}
options={{
mdxOptions: {
remarkPlugins: [
// Adds support for GitHub Flavored Markdown
remarkGfm,
// Makes emojis more accessible
remarkA11yEmoji,
// generates a table of contents based on headings
remarkToc,
],
// These work together to add IDs and linkify headings
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
},
}}
components={mdxComponents}
/>
)
}
You may notice the `components={mdxComponents}` prop. This is where we pass in our custom components that we want to use in our markdown files.
For using with Next.js, we probably want to use the official `next/link` and `next/image` components to opt into client side routing and image optimization.
This is also where I've defined the components like the file trees in this post.
// app/(subpages)/blog/[slug]/components/markdown-components.tsx
import Link from 'next/link'
import Image from 'next/image'
export const mdxComponents: MDXComponents = {
a: ({ children, ...props }) => {
return (
<Link {...props} href={props.href || ''}>
{children}
</Link>
)
},
img: ({ children, props }) => {
// You need to do some work here to get the width and height of the image.
// See the details below for my solution.
return <Image {...props} />
},
// any other components you want to use in your markdown
}
There's probably a better way to accomplish this (that makes use of the `sizes` prop of next/image),
but I add the intended image width and height to the image URL as query parameters.
This allows me to get the width and height from the URL and pass it to next/image.
// app/(subpages)/blog/[slug]/components/mdx-image.tsx
import NextImage from 'next/image'
export function MDXImage({
src,
alt,
}: React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
> & {
src: string
alt: string
}) {
let widthFromSrc, heightFromSrc
const url = new URL(src, 'https://maxleiter.com')
const widthParam = url.searchParams.get('w') || url.searchParams.get('width')
const heightParam =
url.searchParams.get('h') || url.searchParams.get('height')
if (widthParam) {
widthFromSrc = parseInt(widthParam)
}
if (heightParam) {
heightFromSrc = parseInt(heightParam)
}
const imageProps = {
src,
alt,
// tweak these to your liking
height: heightFromSrc || 450,
width: widthFromSrc || 550,
}
return <NextImage {...imageProps} />
}
// In a Markdown file

Bright is a new RSC-first syntax highlighter by code-hike.
It performs the highlighting on the server, so only the necessary styles and markup are sent to the client.
It also has first-class support for extensions like line numbers, highlighting, or whatever you decide to build.
Install the `bright` package and use it in your MDX components like so:
import { Code } from 'bright'
export const mdxComponents: MDXComponents = {
// the \`a\` and \`img\` tags from before should remain
pre: Code,
}
And that's all you need for great syntax highlighting.
Now that we have MDX setup and equipped with our components, we can render a post.
First, let's import the `getPost` function and the `PostBody` component we created earlier.
// app/(subpages)/blog/[slug]/page.tsx
import getPosts from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
Now we just... render the component.
import getPosts, { getPost } from '@lib/get-posts'
import { PostBody } from '@mdx/post-body'
import { notFound } from 'next/navigation'
export default async function PostPage({
params: _params,
}: {
params: Promise<{
slug: string
}>
}) {
const params = await _params
const post = await getPost(params.slug)
// notFound is a Next.js utility
if (!post) return notFound()
// Pass the post contents to MDX
return <PostBody>{post?.body}</PostBody>
}
We can now render a post; that's pretty cool.
We can optionally choose to build all of our posts at build time,
by adding `generateStaticParams` to the page:
export async function generateStaticParams() {
const posts = await getPosts()
// The params to pre-render the page with.
// Without this, the page will be rendered at runtime
return posts.map((post) => ({ slug: post.slug }))
}
The new Metadata API is fantastic, but it's also a major work in progress. Be sure to check the docs for the latest updates.
The new Metadata API has great documentation, so I won't go into too much detail here.
I define the majority of my layout in the root layout and override it as necessary in the leaf pages.
Here's what my root layout's metadata looks like:
// app/layout.tsx
export const metadata = {
title: {
template: '%s | Max Leiter',
default: 'Max Leiter',
},
description: 'Full-stack developer.',
openGraph: {
title: 'Max Leiter',
description: 'Full-stack developer.',
url: 'https://maxleiter.com',
siteName: "Max Leiter's site",
locale: 'en_US',
type: 'website',
// To use your own endpoint, refer to https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation
// Note that an official \`app/\` solution is coming soon.
images: [
{
url: \`https://maxleiter.com/api/og?title=${encodeURIComponent(
"Max Leiter's site"
)}\`,
width: 1200,
height: 630,
alt: '',
},
],
},
twitter: {
title: 'Max Leiter',
card: 'summary_large_image',
creator: '@maxleiter',
},
icons: {
shortcut: 'https://maxleiter.com/favicons/favicon.ico',
},
alternates: {
types: {
// See the RSS Feed section for more details
'application/rss+xml': 'https://maxleiter.com/feed.xml',
},
},
}
export const viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#f5f5f5' },
{ media: '(prefers-color-scheme: dark)', color: '#000' },
],
}
And a pages metadata may look like this:
// app/(subpages)/about/page.tsx
export const metadata = {
title: 'About',
alternates: {
canonical: 'https://maxleiter.com/about',
},
}
See @leeerob's announcement tweet for more details.
// app/sitemap.ts
import { getPosts } from './lib/get-posts'
export default async function sitemap() {
const posts = await getPosts()
const blogs = posts.map((post) => ({
url: \`https://maxleiter.com/blog/${post.slug}\`,
lastModified: new Date(post.lastModified).toISOString().split('T')[0],
}))
const routes = ['', '/about', '/blog', '/projects'].map((route) => ({
url: \`https://maxleiter.com${route}\`,
lastModified: new Date().toISOString().split('T')[0],
}))
return [...routes, ...blogs]
}
While waiting for of an official solution for RSS feeds, I've created a custom solution that works well for me.
I use the `marked` library to parse the markdown files and then use the `rss` library to generate the RSS feed.
This means the JSX components for MDX are passed through to the RSS feed,
so I just try and ensure the components are legible even when not renderd.
// scripts/rss.ts
import fs from 'fs'
import RSS from 'rss'
import path from 'path'
import { marked } from 'marked'
import matter from 'gray-matter'
const posts = fs
.readdirSync(path.resolve(__dirname, '../posts/'))
.filter(
(file) => path.extname(file) === '.md' || path.extname(file) === '.mdx'
)
.map((file) => {
const postContent = fs.readFileSync(\`./posts/${file}\`, 'utf8')
const { data, content }: { data: any; content: string } =
matter(postContent)
return { ...data, body: content }
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
const renderer = new marked.Renderer()
renderer.link = (href, _, text) =>
\`<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>\`
marked.setOptions({
gfm: true,
breaks: true,
renderer,
})
const renderPost = (md: string) => marked.parse(md)
const main = () => {
const feed = new RSS({
title: 'Max Leiter',
site_url: 'https://maxleiter.com',
feed_url: 'https://maxleiter.com/feed.xml',
// image_url: 'https://maxleiter.com/og.png',
language: 'en',
description: "Max Leiter's blog",
})
posts.forEach((post) => {
const url = \`https://maxleiter.com/blog/${post.slug}\`
feed.item({
title: post.title,
description: renderPost(post.body),
date: new Date(post?.date),
author: 'Max Leiter',
url,
guid: url,
})
})
const rss = feed.xml({ indent: true })
fs.writeFileSync(path.join(__dirname, '../public/feed.xml'), rss)
}
main()
I use Vercel for deployments (I may be bias), but you can use any static site provider you want with a setup
like this because it supports static export.
I hope this post was helpful to you.
As a reminder, you can find the source code for this site here.
Please try to not think negatively of me for anything you find (but feel free to offer praise for anything you like).

First, I used Vite to build a small front-end site to demonstrate the library. This let me test the component and demonstrate usage in the same repository. Getting this working was fairly straight forward for someone with experience wrangling TypeScript configurations, but I imagine for a less experienced developer it could've been a daunting task. I ended up with two tsconfigs, `tsconfig.node.json` and `tsconfig.site.json`, had to figure out module resolution, realized I needed `vue-tsc`, and had a multitude of other minor roadblocks. The nicest thing about Vite is it is fast, which made working on this manageable.
Second, I had to figure out how to publish a library. After lots of research (AKA reading Reddit threads and blog posts), I figured out the proper incantation of `tsc` parameters, `package.json` options, and tsconfigs to get something almost functional by channeling the Unix Magic wizard:
I added a *third* tsconfig, `tsconfig.dist.json` (that also extends `tsconfig.node.json`), added a new `vite.config.ts` for distribution (which required special options for passing to rollup), and I added something like the following to my package.json:
"types": "./dist/types/main.d.ts",
"files": [
"dist"
],
"main": "./dist/sortablejs-vue3.umd.js",
"module": "./dist/sortablejs-vue3.es.js",
"exports": {
".": {
"import": "./dist/sortablejs-vue3.es.js",
"require": "./dist/sortablejs-vue3.umd.js"
}
},
The next thing I did was establish some tooling. I set-up Prettier with an npm script to format my code (which requires two new configuration files), a GitHub bot for updating dependencies (which requires a configuration file), and Vercel for deploying the demo site. Someone later submit a pull request for auto-publishing to npm when new tags are pushed, which required a `.github/` directory to be made.
Now I was ready to release the library! Or so I thought: for some reason, \`.github/\` was being included in the package contents. After some digging, I found I needed to add an \`.npmignore\`, something I've done in the past but forgot about. Thankfully, using it again caused me to remember that npm defaults to using your \`.gitignore\`, and when you add an npmignore it no longer does! I copied the contents of my \`.gitignore\` over and added a line for \`.github\`.
Finally, I had a publishable library!
You can see the code at https://github.com/MaxLeiter/sortablejs-vue3, and I hope it's helpful to anyone looking to publish something similar in the future.
]]>This post starts with a brief introduction. If you want to jump down to the tutorial, click here. You can also see the live updating views in action in the top right of this page! The analytics events themselves require no JavaScript and occur completely on the server thanks to the new (in beta) Next.js middleware. If you have JavaScript enabled, the view count should live update thanks to Supabase Realtime. You can try it by opening the page in a new tab and eyeing the view count.
Contents
When possible, I avoid requiring JavaScript and exposing my users to unnecessary tracking. The problem is that I decided I wanted to track views on my blog, and Next.js analytics don't provide the vanity metrics I'm looking for. I previously used Matomo analyzing my nginx log, and while matomo is a fantastic piece of open-source software, I don't want to mess with the PHP API (or use PHP for the first time in ~7 years) in order to get post views on the front-end.
Coincidentally, Supabase announced their Series B a few days ago, and that pushed me to finally give it a try and see how their product works. If you're unfamiliar with Supabase, they're an open-source Firebase alternative with a hosted option: you get a postgres database and fancy dashboard and CLI for creating tables, edge functions, authentication and more. They also make it incredibly easy to subscribe to database updates, making syncing and connectivity a breeze.
So far, I love everything about it except the dashboard: everything is so slow. Creating a new, unnamed SQL query can take 5 seconds:
Dashboard slowness aside, this tutorial will walk you through using Supabase and Next.js middleware to have live-updating view counts and server-side analytics.
Note that this tutorial expects you to already have a Next.js project. If you don't, I recommend following the official Next.js tutorial.
Register an account with Supabase here: https://app.supabase.io/
Create a Supabase project and fill out the new project form.

If the above step confuses you, you need to click your organization in the dropdown of the 'new project' button. That took me a little too long to figure out...

Navigate to the `Table Editor` in the left nav bar and click new table. Fill it out to match below, and be sure to click the gear icon to deselect allowing null:
Alternatively, if you're comfortable with SQL, you can write and save a SQL query that you can use again in the future. It may look something like this:
CREATE TABLE analytics (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slug text UNIQUE NOT NULL,
views bigint DEFAULT 1 NOT NULL,
updated_at timestamp DEFAULT NOW() NOT NULL
);
You can input the query in the `SQL Editor` tab in the left nav bar a few options below the `Table Editor`.
Now, we need to add a stored procedure (AKA a stored SQL function we can call from the Supabase API). Navigate to the `Database` tab in the left nav bar and select the `Functions` menu item inside:
<img
alt="A screenshot showing the functions menu item location in the sidebar"
src="/blog/supabase/sidebar-functions.png"
height="450px"
style={{ margin: 'var(--gap)' }}
/>
Note that Supabase offers Database Functions and Edge Functions — we're using Database Functions!
Click `Create a new Function` in the top right and and fill the settings out to match below. We're establishing a name we can use to reference the function, the schema (AKA what tables it has access to), the return value (`void` in this case), and a single argument that is the page path.
The query is as follows:
BEGIN
IF EXISTS (SELECT FROM analytics WHERE slug=page_slug) THEN
UPDATE analytics
SET views = views + 1,
updated_at = now()
WHERE slug = page_slug;
ELSE
INSERT into analytics(slug) VALUES (page_slug);
END IF;
END;
The function updates the row if it exists (if the `page_slug` argument is in the `slug` column), otherwise it creates a new row.
P.S. I believe I found this query somewhere online but now I can't find the original source — if you recognize it please let me know so I can provide credit!
Now we need to enable Realtime so Supabase broadcasts our changes when we subscribe. Navigate to the `Database` item in the navigation bar and select `Replication` in the side menu. You should see the following page:
Click the `0 tables` button the `Source` column and toggle the new table you created (I called mine `analytics`):
Finally, navigate to the `Settings` at the bottom of the navigation and select the `API` submenu. Copy down the `anon public` API key, the `service_role` API key, and the project URL in the `Configuration` box: we'll need them in the next section.
Do not ever expose or share your `service_role` key — it bypasses row-level security and should only ever be used and seen by your server or you.
First, we need to add the API keys and project URL to our environment, which will let us access them in our code. If you're hosting with Vercel, I recommend using their CLI or dashboard to add the keys, which you can read about here. Otherwise, create or modify your `.env` file to contain the following:
NEXT_PUBLIC_SUPABASE_URL=<your URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon_public_key>
SUPABASE_SERVICE_KEY=<service_role_key>
If you're just creating the .env, be sure to add it to your `.gitignore` so it won't be pushed online.
For security, Next.js doesn't automatically expose your environment variables to the client. If you want to do that, you need to prefix the key with `NEXT_PUBLIC_`. This is a great feature, but don't let it stop you from manually verifying you aren't exposing your service key elsewhere.
Add the `@supabase/supabase-js` package from npm to your project:
yarn add @supabase/supabase-js
or
npm install @supabase/supabase-js
Create two files, `supabase/public.js` and `supabase/private.js`, where you want to; I put them in `lib`. `Private` will contain a connection with the `service_role` key while `public` will use the anon one. Two files aren't necessary, but I like distinguishing them so I can be sure which I'm using.
They should each look something like this:
import { createClient } from '@supabase/supabase-js'
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
) {
throw new Error('Missing env vars SUPABASE_URL or SUPABASE_ANON_KEY')
}
const publicClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
export default publicClient
import { createClient } from '@supabase/supabase-js'
if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
throw new Error(
'Missing env vars SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'
)
}
const privateClient = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
)
export default privateClient
The only difference between `private` and `public` is the second argument passed to `createClient`: change that depending on which you want to use.
Create an API route for submitting views. You could do this directly from the middleware but adding it as a serverless function gives you more freedom to extend it in the future. Also note that anyone can trigger this function, so you may want to protect it by adding a new environment variable and sending that with the request from the middleware. Then, only your server requests will ever have that value and can be verified as legitimate.
import { NextApiRequest, NextApiResponse } from 'next'
import supabase from '@lib/supabase/private'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
// `increment_views` is the name we assigned to the function in Supabase, and page_slug is the argument we defined.
await supabase.rpc('increment_views', { page_slug: req.body.slug })
return res.status(200).send('Success')
} else {
return res.status(400).send('Invalid request method')
}
}
export default handler
The above code is in TypeScript; if you want JavaScript, you can remove the first `import` and change the function definition to `const handler = async (req, res) =>`. I included this to try and encourage you to try TypeScript if you aren't already; it's very helpful for exploring unfamiliar APIs, like the Next.js requests and responses.
Create a `pages/_middleware.{jsx,tsx}` file. The middleware will run on the server before every page request; nothing inside it is ever exposed to the client so we can safely use our private Supabase lib. However, we'll actually just send a POST request to our API handler instead.
import { NextMiddleware, NextResponse } from 'next/server'
const PUBLIC_FILE = /\.(.*)$/
export const middleware: NextMiddleware = async (req, event) => {
const pathname = req.nextUrl.pathname
// we ignore running this middleware when the request is to a serverless function or a file in public/.
// This is purely optional.
const isPageRequest =
!PUBLIC_FILE.test(pathname) && !pathname.startsWith('/api')
const sendAnalytics = async () => {
const slug = pathname.slice(pathname.indexOf('/')) || '/'
// Change your production URL!
const URL =
process.env.NODE_ENV === 'production'
? 'https://maxleiter.com/api/view'
: 'http://localhost:3000/api/view'
const res = await fetch(`${URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
slug,
}),
})
if (res.status !== 200) {
console.error('Failed to send analytics', res)
}
}
// event.waitUntil is the real magic here:
// it won't wait for sendAnalytics() to finish before continuing the response,
// so we avoid delaying the user.
if (isPageRequest) event.waitUntil(sendAnalytics())
return NextResponse.next()
}
You should now verify your views table is updating by loading pages and viewing the Supabase dashboard. If it's not updating, verify your connection details and try exploring the Supabase logs.
Now we can finally add our updating view count. In your React component, import your public Supabase client.
import supabase from '@lib/supabase/public'
Make views a member of your state:
import supabase from '@lib/supabase/public'
const Component = () => {
// You may want to pass in an initial value for views if you're using getStaticPaths or similar.
// That way, it won't start at 0 when the client loads.
const [views, setViews] = useState(0)
return (...)
}
Add a `useEffect` hook to subscribe and unsubscribe to changes on mount or unmount respectfully. This is all it takes to subscribe to recieve the changes made to the `analytics` table.
import supabase from '@lib/supabase/public'
const Component = () => {
// You may want to pass in an initial value for views if you're using getStaticPaths or similar.
// That way, it won't start at 0 when the client loads.
const [views, setViews] = useState(0)
// Subscribe to view updates.
// Note that `id` is something I store manually on page creation so I can associate
// each page with itself from the DB.
// In practice, I recommend looking into subscribing to low level changes:
// https://supabase.com/docs/reference/javascript/subscribe#listening-to-row-level-changes
useEffect(() => {
const sub = supabase
.from('analytics')
.on('UPDATE', (payload) => {
if (payload.new.id === id) {
setViews(payload.new.views)
}
})
.subscribe()
// The return function of a useEffect is fired on unmount
return () => {
sub.unsubscribe()
}
}, [id])
return (
<div>
{views} {views.length === 1 ? 'view' : 'views'}
</div>
)
}
And there you have it! Automatically updating analytics that don't require JavaScript to be recorded! You can verify it works by opening the page in a new tab.
There's quite a lot you can do from here, but here are some recommendations:
I decided to take a look into Supabase (coincidentally, they just raised a modest $80M series B). I'm happy I did, because the ease of setting up the database and realtime support was incredible. With a few hours of work I've created a live-updating quiz where you can see others results (after you've voted yourself). You can try the quiz at /ie-or-css3. I also recommend listening to the Syntax fm segment, timestamped here.
]]>TL;DR:
WAVE is a suite of tools for finding, diagnosing, and fixing accessibility problems. It's my first step for finding the lowest hanging improvements. It helps you not just fix your errors but understand them by guiding you through the relevant part of the HTML5 spec in plain language. Below you can see a screenshot of the tool being applied to https://webaim.org.
Loading times are important. Someone more business savvy than me would say something about bounce rates and loading times being correlated, but I won't do that. There are a lot of tools for automating this, like WebP Express for WordPress, but no complicated infrastructure is necessary. For things similar to this blog I manually compress the images locally using ImageOptim, an open-source Mac app. Be sure to use the right tool for the job though; for example, I use svgo for optimizing SVGs. I also haven't found a better PNG optimizer than the online https://tinypng.com/, so if you have a replacement that works locally please let me know!
This has been covered pretty extensively so I’ll keep it short, but use tags like `header`, `footer`, `nav`, and `main`. MDN has a great list of all the valid elements here: https://developer.mozilla.org/en-US/docs/Web/HTML/Element. Using semantic HTML means you can avoid manually assigning `role` tags while keeping your HTML legible for yourself and the page usable for users of assistive technologies. Be sure to also familiarize yourself with and use the relevant ARIA labels when a semantic element isn't possible or if they would improve the user experience.
Unlike a lot of other advice that will say don’t do this because users might have JavaScript disabled, I think the more important reason to do this is you can’t do it better than the browser (after all, 99.4% of users have JavaScript enabled[^1]). I don’t say this because the browser developers are “better” than you, but because the user is used to the browser’s controls. The first item of this list should really be “don’t subvert the users' expectations.” Don’t subvert the users' expectations. Handling the nuances of keyboard and event handling, form submissions, and similar are difficult enough.
I don’t mean how to view the console and read `console.log`s. I mean investing in learning how to use the debugger, read the network tab, inspect (and understand) memory snapshots, and monitor your bundle size to be sure you’re not including unnecessary dependencies. If you’re using Next.js, I recommend setting up the @next/bundle-analyzer package. If you’re using react, redux or vue, check out their browser extensions for useful debugging tools and component inspection.
[^1]: According to https://webaim.org/projects/screenreadersurvey9/#javascript, which absolutely has some selection bias.
]]>Yesterday, when I probably should have been doing something productive, I instead asked hacker news users for links to their personal sites.
It got far more replies than I expected (over 1000 at the time of writing) so I had the idea to do some scraping and see how commenters build and host their sites.
I wrote a quick-and-dirty typescript script (at the bottom of page) for scraping, pinging, and sorting the comments. I could've used a pre-existing library or service for determining tech used on the sites, but where's the fun in that? I’d have loved to add features like checking for git repository links and doing some API calls for a more in-depth analysis, but I’ll leave the complicated parts as exercises for the reader.
If I were to do this all again, I would absolutely not choose JavaScript. Just 0 real benefits; I spent more time than I care to admit bashing my head before remembering `Promise.allSettled` exists. I should've just used Python.
Of the 721 top level comments, 692 contained links and were analyzed at the time of writing. I only looked at the first link in a comment, timed out after 30 seconds, and skipped invalid/self-signed certificates. Also not that none of the categories are exclusive — sites can count towards multiple stats.
Instead of using a proper DOM scraping library or anything, I just search the headers and page text. Not the most efficient or accurate, so if you find an issue please let me know! You can see my source code here, although I'd recommend just doing it yourself from scratch and not scarring your eyes.
Thanks to Hacker News user retSava for this comment providing percentages, something I overlooked.
]]>I've wanted a Gist alternative for years, but it wasn't until I saw this tweet I decided to sit down and build it:
"What is the absolute closest thing to GitHub Gist that can be self-hosted? In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things." - <a href="https://twitter.com/emilyst" style={{color: "var(--link)"}}>@emilyst
If your response to that is "why?", I recommend reading 'Why you should start self-hosting' by Rohan Deshumkh. Also know that I have a habit of starting time consuming weekend projects to procrastinate school work, and this seemed like an especially fun one.
The primary goal of Drift is to capture the value proposition of GitHub. Syntax highlighting, markdown rendering, straight to the point. There are tons of self-hostable pastebins, and none target an experience like Gist. You can try a demo here.
Here's a screenshot of the authenticated homepage (don't worry, there's a light theme too):
And here's a screenshot of viewing a post, which you can visit in your browser here:
For prototyping, I used the geist-ui library for the basic design components. It's very JavaScript reliant, but I've already begun working on replacing it with in-house elements. So if you're reading this from Hacker News and were planning to complain it doesn't work — I'm sorry!
Rendering markdown, after multiple iterations, is done and stored on the server. This means that published posts can be rendered at build-time or on the server thanks to Next.js's SSR support. For editing, I've set-up a Next.js lambda function that receives markdown from the client and returns HTML.
Some other features are authentication, drag-and-drop file uploading, password protected posts, and support for GitHub Extended Markdown.
I'm particularly happy with the drag-and-drop uploading tied with the automatic syntax highlighting, as they lead to a great workflow for sharing files, which you can see a demo of here:
The majority of these features are largely thanks to the Node and React ecosystems, so thank you to the maintainers of those projects.
If Drift is interesting to you, please feel free to contribute, regardless of skill level. There have already been numerous community contributions, including the logo and docker-compose. I've tried to keep it fairly simple, so the back-end is a simple Express server and the client is a Next.js React app. You can join #drift on Libera.Chat to get in touch. You can find Drift on GitHub here.
]]>First off, it's a good idea! Contributing to projects is a fantastic way to build experience, connections, and improve a piece of software you use or rely on. I've grown immensely through maintaining my few projects and contributing to a hand full of others. I've gained experience in community management, team communication, code review, dependency management, and the list goes on. This post is focused on programming, but don't forget that issue management, documentation, user support, and bug reporting are all valuable ways you can contribute.
That point about using open-source software is important. The first thing I tell my friends is they should first ensure they're using some before they attempt to contribute. There are few things that are more satisfying than fixing a bug that's been bugging you in a program written by someone else, and the motivation that provides is not inconsequential[^1]. As a maintainer, contributor, and extensive procrastinator I've seen and felt lack of motivation and burn-out from multiple perspectives, and know how daunting making a contribution can be. So to give you some ideas, here are some applications I use that you may want to try using (or may already use!), and a checklist for how to get started contributing to them:
And loads more. I didn't even mention the vast number of open-source software libraries and frameworks you likely already use. Maybe Next.js, Vue, or Flask are of interest to you, just to provide a few examples. And below is my framework for getting started.
...which you can read more about in my dedicated post.

[^1]: One of the few better feelings is fixing a bug in software you made that someone else reported.
[^2]: If the README does not exist, consider that a red flag. If the CONTRIBUTING file does not exist, consider asking the maintainers if you could help draft one.
[^3]: If you haven't worked professionally in CS yet, this is likely your first code review, and they're highly prevalent in the industry, so try extra hard to learn from your mistakes and take note of what you like and dislike about the review left for you.
My recommendation is to pin your dependencies. If you're unfamiliar with what this means, you should first be familiar with semantic versioning. In short, pinning dependencies means the exact version specified will be installed, rather than a dependency matching the range criteria. Here's an example:
{
"dependencies": {
"react": "17.0.2", // installs [email protected] exactly. I recommend this.
"react": "^17.0.2", // installs the latest minor version after .0 (so 17.*.*)
"react": "~17.0.2" // installs the latest patch after .2 (so 17.0.*)
}
}
To automatically accomplish this in your projects, you can add `save-exact=true` to a `.npmrc` file, or use `--save-exact` when adding the dependency via npm (or `--exact` via yarn).
If you do this, I also recommend some sort of dependency management so you stay up-to-date. I personally use the renovate bot for my GitHub projects.
You may have noticed npm and yarn generate `package-lock.json` and `yarn.lock` when you run `npm i` and `yarn`, respectively. The lockfiles allow every version of every sub-dependency to be pinned, while modifying the package.json only guarantees we pin the immediate dependency (which may not pin its own dependencies).
One problem with relying on lockfiles is that they and their semantics are confusing. In my experience, many new-to-npm users expect `npm i` to function like `npm ci`, and are unaware of the differences. `npm i` can update `package-lock.json`, whereas `npm ci` will only read from it.
Tip: You almost always want to use `npm ci`
Meanwhile, with yarn, `yarn install` will install the version in the yarn.lock, regardless of the version in the package.json. Good for security? Sure. Good for the developer experience? Not so much. You need to use `yarn upgrade <package>` in order to fetch the version in the package.json if it's more recent than specified in the yarn.lock. It's now far more difficult to determine when something is updated. If you've ever found yourself staring at a yarn.lock diff you'll know what I mean.
Lockfiles are good tools, but can be difficult and unintuitive to work with.
Use lockfiles for pinning transitive dependencies, but have your package.json be the source of truth. Pin your dependencies so you have a human-friendly, readable method to audit your dependencies and ensure a specific version is installed. Automate some process to make sure you remain up-to-date.
If you disagree or have comments, feel free to email me or reach out on Twitter.
]]>Before this post, I kept a list of my programs in my iCloud in case my computer broke or I was setting up a work laptop. After some recent events, I trust iCloud less than I once did, and figured the list can live here instead. Like the rest of my blog, this exists for me but hopefully you find it useful too. I plan to write an Arch Linux version of this soon.
I try to use open-source where I can. If you know of an alternative to the few closed applications I use, please let me know!

Usage is pretty simple:
useMousetrap("escape", (evt, combo) => {
evt.preventDefault
}, "keypress")
import mousetrap from "mousetrap"
import { useEffect, useRef } from "react"
const useMousetrap = (
handlerKey: string | string[],
handlerCallback: (evt: KeyboardEvent, combo: string) => void,
evtType?: "keypress" | "keydown" | "keyup",
) => {
let actionRef = useRef(handlerCallback)
useEffect(() => {
mousetrap.bind(
handlerKey,
(evt: KeyboardEvent, combo: string) => {
typeof actionRef.current === "function" && actionRef.current(evt, combo)
},
evtType,
)
return () => {
mousetrap.unbind(handlerKey)
}
}, [evtType, handlerKey])
}
export default useMousetrap
]]>To get started, you'll need to generate a Developer Token on the control panel. Navigate to the `Sub-accounts` page and generate a new API key at the bottom of the page:

Also, install axios (or change the below code to your favorite request library, I don't care):
yarn add axios
Then, incorporate these two files:
import axios, { AxiosRequestConfig } from 'axios'
import { InitializationOptions, MetricResponse, RestartResponse } from './types'
export default class Client {
private instance: string
constructor({
apiKey,
apiSecret,
apiUrl = 'https://api.creeper.host',
instanceId,
}: InitializationOptions) {
axios.defaults.baseURL = apiUrl
axios.defaults.headers.common['key'] = apiKey
axios.defaults.headers.common['secret'] = apiSecret
axios.defaults.headers.common['Content-Type'] = 'application/json'
this.instance = instanceId
}
private async request<T = any>({
method = 'GET',
apiRoute,
body,
}: {
method: 'GET' | 'POST'
apiRoute: string
body?: any
}) {
const config: AxiosRequestConfig = {
method,
url: apiRoute,
data: body,
}
console.log(
`[${method}] ${apiRoute}`,
body ? `\t ${JSON.stringify(body)}` : '',
)
const request = await axios.request<T>(config)
return request.data
}
public os = {
getram: async () => {
return this.request<MetricResponse>({
method: 'GET',
apiRoute: 'os/getram',
})
},
getssd: async () => {
return this.request<MetricResponse>({
method: 'GET',
apiRoute: 'os/getssd',
})
},
}
public minecraft = {
restartserver: async () => {
return this.request<RestartResponse>({
method: 'POST',
apiRoute: 'minecraft/restartserver',
body: { instance: this.instance },
})
},
}
}
export type MetricResponse =
| {
status: 'success'
free: number
used: number
}
| {
status: 'error'
message: string
}
export type RestartResponse =
| {
status: 'success'
message: string
}
| {
status: 'error'
message: string
}
Finally, create an instance via `new Client({ apiKey, apiSecret, instanceId });`
You can use it by finding what you want to call on the Creeper Host docs and accessing that on the client; I purposefully used the same naming scheme. For example,
Client.os.getram(): Promise<MetricResponse>
corresponds to the https://api.creeper.host/os/getram endpoint.
I removed some methods from the above to avoid cluttering this page, but it's straight forward to add your own. If you really want to use this, contact me and I can set up a repo for us to collaborate. Also, if you have suggestions on how I can improve this, let me know via email or on Twitter.
]]>git fetch https://github.com/${team}/${respository}.git refs/pull/${prId}/head
git checkout FETCH_HEAD
git rebase master # or main
Just replace `team`, `repository` and `prId` and you're all set.
I picked this script up from The Lounge: https://github.com/thelounge/thelounge/blob/master/scripts/run-pr.sh. It's a more complete example if you want to start using a script like this. Notice the \` and bash substitution (\`) at the end of it — these allow you to pass in command-line arguments to `yarn start` (or whatever your run script is).
]]>Here's what I got:
I bought everything from the Framework store (at a slight markup of ~$80 for the RAM, SSD, and wifi card), but I'm happy to support them and for the convenience of knowing it would work properly was well worth it.

I'll let my Tweet from a few days ago sum it up:
]]>I knew nothing (and still know very little) about the Linux kernel or drivers, but this seemed like a great place to start. Luckily, GitHub user archseer discovered that the MSHW0184 registers align with the APDS9960 device, which already has an upstream kernel driver, and he mentioned all that should be needed is a small change to allow the driver to detect the new device.
Here I realized I had to do some research into how the kernel loads drivers. I knew that drivers could either be statically built into the kernel, or built as kernel modules so they can be dynamically loaded when they're needed. But how does the kernel know when to load specific drivers for certain hardware? You register the ID with a matching table.
Initially, I tried adding the MSHW0184 device ID to the existing match table in the APDS9960 driver for the i2c protocol. This consisted of the following one-line change:
static const struct i2c_device_id apds9960_id[] = {
{ "apds9960", 0 },
+ { "MSHW0184, 0 }
{}
}
However, this didn't work and I realized the conventions seemed a little odd: no other i2c device ids in other drivers contained capitals or were ambient light sensing devices. Thankfully, someone on IRC helped me out:
00:53 <djrscally> That's an acpi ID
00:53 <djrscally> You probably need to add an ACPI match table
A few minutes later (well, probably closer to 30 minutes after compiling and testing the kernel on-device) I had the following, largely copied from other ACPI drivers:
+ static const struct acpi_device_id apds9960_acpi_match[] = {
+ { "MSHW0184" },
+ { }
+};
+ MODULE_DEVICE_TABLE(acpi, apds9960_acpi_match);
static struct i2c_driver apds9960_driver = {
.driver = {
.name = APDS9960_DRV_NAME,
.of_match_table = apds9960_of_match,
.pm = &apds9960_pm_ops,
+ .acpi_match_table = apds9960_acpi_match,
},
.probe = apds9960_probe,
.remove = apds9960_remove
I recompiled the kernel module, rebooted, and verified the driver was matched with lsmod and ensured the driver paired with the device by navigating to the IIO device (finding the path to the device took a lot more work than I'd like to admit) and reading in the `in_intensity_clear_raw` file:
max@surface ~> cat sys/bus/iio/devices/iio:device0/in_intensity_clear_raw
33
The joy I felt seeing that file exist and the output was monumental. I had something to work with!
I wasn't done yet though — GNOME still didn't show me an option to automatically adjust the screen brightness. After someone else verified they also had the device loaded with the modified driver but not in GNOME I determined the problem was somewhere in iio-sensor-proxy. I cloned the iio-sensor-proxy repository and started digging.
The first thing I always do when I clone a new repo is expand and quickly look over every folder (assuming the repo is a reasonable size) and that served me well here. I found the following file `80-iio-sensor-proxy.rules`:
# iio-sensor-proxy
# IIO sensor to D-Bus proxy
ACTION=="remove", GOTO="iio_sensor_proxy_end"
# Set the sensor type for all the types we recognise
SUBSYSTEM=="hwmon", TEST=="light", ENV{IIO_SENSOR_PROXY_TYPE}+="hwmon-als"
SUBSYSTEM=="iio", TEST=="in_accel_x_raw", TEST=="in_accel_y_raw", TEST=="in_accel_z_raw", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-poll-accel"
SUBSYSTEM=="iio", TEST=="scan_elements/in_accel_x_en", TEST=="scan_elements/in_accel_y_en", TEST=="scan_elements/in_accel_z_en", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-buffer-accel"
SUBSYSTEM=="iio", TEST=="scan_elements/in_rot_from_north_magnetic_tilt_comp_en", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-buffer-compass"
SUBSYSTEM=="iio", TEST=="in_illuminance_input", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-poll-als"
SUBSYSTEM=="iio", TEST=="in_illuminance0_input", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-poll-als"
SUBSYSTEM=="iio", TEST=="in_illuminance_raw", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-poll-als"
SUBSYSTEM=="iio", TEST=="scan_elements/in_intensity_both_en", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-buffer-als"
SUBSYSTEM=="iio", TEST=="in_proximity_raw", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-poll-proximity"
SUBSYSTEM=="input", ENV{ID_INPUT_ACCELEROMETER}=="1", ENV{IIO_SENSOR_PROXY_TYPE}+="input-accel"
ENV{IIO_SENSOR_PROXY_TYPE}=="", GOTO="iio_sensor_proxy_end"
# We got here because we have a sensor type, which means we need the service
TAG+="systemd", ENV{SYSTEMD_WANTS}+="iio-sensor-proxy.service"
As you might be able to figure out, each TEST file is checked for existence and is used to determine whether or not the device should be used by iio-sensor-proxy.
I hadn't seen any of the seemingly relevant files, inilluminance*, so I added my own line:
SUBSYSTEM=="iio", TEST=="in_intensity_clear_raw", ENV{IIO_SENSOR_PROXY_TYPE}+="iio-poll-als"
After another small iio-sensor-proxy change and running again the device was now discovered by iio-sensor-proxy! The full merge request can be seen here. The option appeared in GNOME and I now have automatic adjusting brightness!
It kind of sucks though and isn't very consistent. As it turns out, intensity_clear != illuminance, which is what most programs expect from ambient light sensors, so I need to figure out and perform some math in iio-sensor-proxy to translate the RGBC values to lux.
If you've made it this far (or are here to get the code yourself), the modified driver (with basic proximity sensing support too) can be found here: https://github.com/maxleiter/MSHW0184.
Major thanks to everyone that helped me in the ##linux-surface IRC channel. It was great to finally get a little low-level with Linux.
*This post was originally written on the old maxleiter.com, and formatting has slightly adjusted
]]>I'm excited to announce that X11 is coming soon to iOS. Most (see below) packages and dependencies for a fully functioning X11 desktop system have been compiled and are available on Cydia for iOS 11+. All packages have been compiled for arm64 and have been tested on iOS 12.4 and iOS 13.1. This requires a jailbroken device.
This site will serve largely as documentation for building yourself. You can add the Cydia repo below for the deb packages. Please let me know if you run across any issues with the debs; it's likely I messed up including a library or something like that. (These aren't done just yet.)
At the moment, a virtual screen is accessed via a VNC client to an Xvnc instance running on the iDevice. If you're unfamiliar, Xvnc is an X server with a virtual screen that can be accessed via VNC. The best part of this is no drivers are required: it's all handled by Xvnc.
Largely just because I can. Also, I want to turn my iPad into a proper development environment, and a windowing system helps with that. It's a powerful machine with a Unix-like OS, so X11 seemed like a reasonable project. X11 allows running arbirtary applications like browsers and IDEs, assuming you can make them compile.
Before you start, be sure to have an iOS SDK located at `/usr/share/iPhoneOS.sdk` for sbingner's llvm-10 to function. You can get the SDKs from theos.
The following tools/libraries are required to build the packages (unless you choose to build these yourself):
From MCApollo's repository:
In general, you can follow the instructions from Beyond LinuxFromScratch. This project is based on 9.0, with version differences or code modications marked with ⚠️ below. Applications and libs marked with ❗️ means unavailable on iOS or I was unable to build them. Empty notes means compiles and works as-is from BLFS. Additionally, all of the packages are available (generally as dylibs) on the Cydia repo linked above.
| Name | Version | Notes |
|---|---|---|
| util-macros | 1.19.2 | |
| xorgproto | 2019.2 | |
| libXau | 1.0.9 | |
| libXdmcp | 1.1.3 | |
| xcb-proto | 1.13 | |
| libxcb | 1.13.1 | |
| Freetype | 2.10.1 | |
| Fontconfig | 2.13.1 ⚠️ | Requires modifying stdlib.h in SDK to allow system calls. Bug with patch available here. |
| xtrans | 1.4.0 | |
| libX11 | 1.6.9 | |
| libFS | 1.0.8 ⚠️ | Need to remove some code from the libtool file. The enviroment variables aren't populated on iOS so a generated bash command is wrong. Check the output of ./configure to pinpoint the line number. |
| libICE | 1.0.1 | |
| libSM | 1.2.3 | |
| libXScrnSaver | 1.2.3 | |
| libXt | 1.2.0 | |
| libXmu | 1.1.3 | |
| libXpm | 3.5.13 | |
| libXaw | 1.0.13 | |
| libXfixes | 5.0.3 | |
| libXcomposite | 0.4.5 | |
| libXrender | 0.9.10 | |
| libXcursor | 1.2.0 | |
| libXdamage | 1.1.5 | |
| libfontenc | 1.1.4 | |
| libXfont2 | 2.0.4 | |
| libXft | 2.3.3 | |
| libXi | 1.7.10 | |
| libXinerama | 1.1.4 | |
| libXrandr | 1.5.2 | |
| libXres | 1.2.0 | |
| libXtst | 1.2.3 | |
| libXv | 1.0.11 | |
| libXvMC | 1.0.12 | |
| libXxf86dga | 1.1.5 | |
| libXxf86vm | 1.1.4 | |
| libpciaccess | 1.0.12 ❗️ | Unsupported OS |
| libdmx | 1.1.4 | |
| libxkbfile | 1.1.0 | |
| xcb-util | 0.4.0 | |
| xcb-util-image | 0.4.0 | |
| xcb-util-keysyms | 0.4.0 | |
| xcb-util-keysyms | 0.4.0 | |
| xcb-util-renderutil | 0.3.9 | |
| xcb-util-wm | 0.4.1 | |
| xcb-util-cursor | 0.1.3 | |
| Mako | 1.1.0 | If you can't build yourself, try using pip |
| Mesa | 0.52.1 ⚠️ | Set DRI drivers to swrast and set gallium drivers to empty. I manually modifed meson_options.txt. Also symlink m4 to /opt/local/bin/gm4. I had to remove an __APPLE__ check in /src/mesa/main/texcompress_s3tc_tmp.h so it used the GL library instead of the macOS OpenGL one. Also, be sure to specify a minimum iOS version so thread-local support works with something like -miphoneos-version-min=11.2. |
| xbitmaps | 1.1.2 | |
| iceauth | 1.0.8 | |
| luit | 1.1.1 | Run sed -i -e "/D_XOPEN/s/5/6/" configure |
| mkfontdir | 1.0.7 | |
| mkfontscale | 1.2.1 | |
| sessreg | 1.1.2 | |
| setxkbmap | 1.3.2 | |
| smproxy | 1.0.6 | |
| x11perf | 1.6.1 | |
| xcursorgen | 1.0.7 | |
| xdpyinfo | 1.3.2 | |
| xev | 1.2.3 | |
| xhost | 1.0.8 | |
| xinput | 1.6.3 | |
| xkbcomp | 1.4.2 | |
| xkbevd | 1.1.4 | |
| xkbutils | 1.0.4 | |
| xkill | 1.0.5 | |
| xlsatoms | 1.1.3 | |
| xlsclients | 1.1.4 | |
| xmessage | 1.0.5 | |
| xmodmap | 1.0.10 | |
| xpr | 1.0.5 | |
| xprop | 1.2.4 | |
| xrandr | 1.5.1 | |
| xrdb | 1.2.0 | |
| xrefresh | 1.0.6 | |
| xset | 1.2.4 | |
| xsetroot | 1.1.2 | |
| xvinfo | 1.1.4 | |
| xwd | 1.0.7 | |
| xwininfo | 1.1.5 | |
| xwud | 1.0.5 | |
| startup-notifcation | .12 | |
| xterm | 351 | |
| FLTK | 1.3.5 ⚠️ | Need to remove some -U__APPLE__ calls. Disable tests in Makefile. |
| tigervnc | 1.10.1 ⚠️ | No vncviewer. Disable it via CMake. Remove the if(NOT APPLE) check above the add_subdirectory(unix). Also remove the find_package(FLTK) check and remove the line add_subdirectory(tests) (as some tests require FLTK). It's important to build tigervnc before building Xvnc. |
| Xvnc | 1.10.1 ⚠️ | Add a fake Xplugin.h to fool /unix/xserver/miext/rootless. Modify rootlessWindow.c to check for "Xplugin.h" instead of \ |
| Glib | 2.62.4 ⚠️ | Disable cocoa and carbon support in build.meson. Had to fake crt_externs.h. Remove the if host_system == 'darwin' check in glib/meson.build. |
| LuaJIT | 2.1.0 | https://github.com/rweichler/luajit-ios |
| OpenJPG | 2.3.1 | |
| poppler | 0.84.0 | |
| check | 0.11.0 | |
| pango | working on it | |
| EFL | 1.23.3 | working on it |
| compton | git v0.1 beta g316eac0 | |
| FOX toolkit | 1.7.67 | Contains Adie (text editor), a calculator, and Shutterbug (screenshots) |
| azpainter | 4bf18c8 |
*This post was originally written on the old maxleiter.com, and formatting has slightly adjusted
]]>