<![CDATA[Max Leiter]]> https://maxleiter.com RSS for Node Thu, 09 Apr 2026 03:01:39 GMT <![CDATA[Per-directory terminal colors in fish shell]]> My terminal background color changes based on the current directory.
It's a small touch that makes it easy to tell which project I'm in at a glance, which is
especially useful when juggling AI agents.

It works via a custom fish function called _color_for_dir, which:

  1. Hashes the current $PWD
  2. Maps the hash to a hue (0-359)
  3. Converts it to a dark RGB color
  4. Sends an OSC 11 escape sequence to change the terminal background
# 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:

Click to expand function _claude_color_for_dir # Skip in VS Code's integrated terminal if test "$TERM_PROGRAM" = vscode return end
# 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

]]>
https://maxleiter.com/notes/fish-directory-colors https://maxleiter.com/notes/fish-directory-colors Sun, 05 Apr 2026 00:00:00 GMT
<![CDATA[Sandcastle: A web-based Linux desktop environment]]>

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.

If you're familiar with Desktop Environments and Linux, you can [skip the next few sections](#the-servers)

Table of Contents

X11?

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?

Desktop Environments?

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:

Sway is a tiling window manager: you don't really drag windows around, you use your keyboard and the windows tile against each other.

And here's Sandcastle:

A screenshot of Sandcastle showing xeyes, GIMP, and a file explorer

[xeyes](https://www.reddit.com/r/linuxquestions/comments/1aur066/what_is_the_purpose_of_xeyes/), [GIMP](https://www.gimp.org/), and a file explorer. At the top you can see the desktop icons.

So that kind of covers the client (more below), but what's the server here?

The servers

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.

Enter Sandcastle

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:

GIMP, gnome-calculator, Firefox, and a system monitor running

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          │
                                  └──────────────────┘

Pieces of the castle

Xpra

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.

Ghostty-web

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.

Hiding window decorations

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):

A screenshot of gnome-calculator with two stacked window decorations: mine and GNOME's

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.

D-Bus and notifications

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:

  1. org.freedesktop.Notifications: implements the Desktop Notifications Specification, so notify-send and GLib apps can send notifications
  2. org.freedesktop.ScreenSaver: stubs the screensaver inhibit interface so apps like video players don't think the session is idle
  3. 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
    • The tricky part was getting notifications from inside a Linux VM to a React app in the browser. The (gross) pipeline looks like this: notify-send → D-Bus → Python bridge → JSON file → Node.js HTTP API → polling → toast UI
    • The bridge daemon writes its state to a JSON file. A Node.js service inside the sandbox reads that file and exposes it as HTTP routes. Any Linux app that knows how to send a notification will show a native desktop notification on your machine.
    • The same JSON file is how theme syncing works in reverse: when you toggle dark mode in the browser, it POSTs the new color-scheme to the Node.js service, which writes it to the JSON file, which the Python daemon reads. When the value changes, it emits a SettingChanged D-Bus signal, and GTK apps pick up the theme change in real-time.

But what about mobile?

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.

Ignore the busted terminal after resizing. I did say this was vibecoded.

"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!

Vibecoding takeaways

This is generally what my IDE looked like while working on this:

A screenshot of VS Code with 6 terminals open: one for the dev server, one for Codex, four for OpenCode

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.

Wrapping up

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.

]]>
https://maxleiter.com/blog/sandcastle https://maxleiter.com/blog/sandcastle Wed, 11 Feb 2026 00:00:00 GMT
<![CDATA[How we made v0 an effective coding agent]]>
Read on vercel.com]]>
https://vercel.com/blog/how-we-made-v0-an-effective-coding-agent https://vercel.com/blog/how-we-made-v0-an-effective-coding-agent Wed, 07 Jan 2026 00:00:00 GMT
<![CDATA[Introducing HFSViewer]]> Last year my roommate and I found an old original iMac G3 outside our apartment building.
It has some interesting early 2000s/late 90s style websites and files on it that I want to preserve (and hope to share here soon!).

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.

You should try to stick to USBs with less than 2 GBs of storage, otherwise Mac OS 9.2 might not support it.

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:

homepage
an open directory

]]>
https://maxleiter.com/blog/HFSViewer https://maxleiter.com/blog/HFSViewer Fri, 02 Jan 2026 00:00:00 GMT
<![CDATA[The new maxleiter.com]]> I redesigned maxleiter.com this week. I've wanted to make it more personal and opinionated for a long time,
but felt I lacked the expertise to do it justice.
With v0 and Claude Code (and some manual cleanup) it only took a few hours.

If you haven't played with the homepage yet, some of the features are:

  • Draggable and resizable windows, including the blog posts
  • A web-friendly version of KnightOS in the Calculator app
  • A terminal app with some fun easter eggs
  • You can full-screen the windows, and some redirect to real pages (like the blog posts)

Going into this I had some constraints:

  • Remains snappy and is SSR'd at build-time
  • Must work without JavaScript enabled
  • Uses new web platform features

maxleiter.com screenshot

Table of Contents

Preloading iframes

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:

  • Sending every post in the initial payload (not great for performance)
  • Fetching the post content on demand (not great for UX)

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

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.

Progressive Enhancement

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>

The Time Doesn't Flash

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()})()`,
  }}
/>
If you want to know why I set the clock's `textContent` and `window.__INITIAL_TIME__`, you'll need to read Ethan's post.

Conclusion

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.

]]>
https://maxleiter.com/blog/the-new-maxleiter-com https://maxleiter.com/blog/the-new-maxleiter-com Wed, 12 Nov 2025 00:00:00 GMT
<![CDATA[You should be rewriting your prompts]]> I've been "lucky" to work with a lot of different LLMs over the past few years (there is, in fact, a reason we made the AI SDK).
When a new model is publicly released there's a good chance we've already tried it and evaluated it for use with v0 within a few hours.

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:

  • Prompt Format
  • Position Bias
  • Model Biases
    • Work with the model biases, not against

Reason #1: Prompt Format

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.

Reason #2: Position Bias (AKA location matters)

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:

Table comparing Qwen and Llama models' accuracy across languages (English, Russian, German, Hindi, Vietnamese) for QA tasks, showing position bias by context placement (Top, Middle, Bottom). Each cell reports accuracy under three instruction strategies (Aligned, All-Zero, No-Scores), with means. Bolded numbers mark best performance. Overall, Qwen is more consistent, with slightly higher bottom-position scores, while Llama shows stronger top bias but more variability, especially in German and Hindi.

From "[Position of Uncertainty: A Cross-Linguistic Study of Position Bias in Large Language Models](https://arxiv.org/pdf/2505.16134)" (2025)

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.

Reason #3: Model Biases

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.

Reason #3a: Work with the model biases, not against

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.

So you can overfit prompts

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.)

]]>
https://maxleiter.com/blog/rewrite-your-prompts https://maxleiter.com/blog/rewrite-your-prompts Sun, 14 Sep 2025 00:00:00 GMT
<![CDATA[I have seen the future through AR glasses]]> I like to try new technology. 75% of what I buy is something I use once or twice then sell or give away (or keep in a drawer and forget about. Sorry, remarkable).
But sometimes, on rare occasions, there's a product that is A) fucking awesome and B) I can't help but evangelize.

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.

The Good

  • They are light. Because they're really just shells and they're powered by your devices, there's no need for a heavy battery
  • You can just look forward. No need to be staring at a small phone screen when you're on a flight.
  • The "Widescreen" mode is epic. Generally, you have one window in front of you, taking up your FOV. With widescreen mode its like having an IMAX display curved 120 degrees around.
  • Gaming on them is awesome.

The Bad

  • The 1080p screen is definitely usable, but Retina screens on Macs spoiled me.
  • I've heard Apple Vision Pro's have similar issues, but on airplanes the "Anchor" mode (that keeps the image fixed in a single spot in space, so you can look around and come back to it) gets just a little confused. If the plane turns, you'll find your head at 90 degrees trying to follow it.
  • They can get a little warm. Nothing too uncomfortable, but it's noticeable.
  • They're definitely ugly. I don't mind wearing them on planes, but they aren't something you'd wear walking around outside just yet

Is AR the future?

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.

]]>
https://maxleiter.com/blog/seeing-the-future https://maxleiter.com/blog/seeing-the-future Sun, 07 Sep 2025 00:00:00 GMT
<![CDATA[Formatting code should be unnecessary]]> I had a (maybe slightly overqualified) computer science teacher back in highschool, Mr. Paige.
He worked on the Ada compiler and
has been programming since the early 80s.

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.

The R1000 is fairly undocumented and very rare, but it was used in the writing of software for the ISS, the F-22, presumably countless other government projects, and led to the [birth of UML by Grady Booch](https://en.wikipedia.org/wiki/Grady_Booch#Booch_method).

DIANA (Descriptive Intermediate Attributed Notation for Ada) was a key component of Ada that enabled a lot of the more advanced features.

A diagram scanned from a type-written page. It shows a diagram of Ada Source => Syntax & Semantics => DIANA => Normalization phase => Middle End => simple IR optimization phase => IR => Code Gen. => Executable Image

Taken from [Experiences with Code Generation (1984)](https://www2.eecs.berkeley.edu/Pubs/TechRpts/1985/CSD-85-249.pdf)

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.

Further reading

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:

]]>
https://maxleiter.com/blog/formatting https://maxleiter.com/blog/formatting Sat, 06 Sep 2025 00:00:00 GMT
<![CDATA[Vibe-coding Minecraft mods]]> {/* eslint-disable react/jsx-no-undef */}

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:

{
Note that trains (and windmills and assembly lines) are not built into Minecraft.
}

So I was pretty lost when I started playing for the first time in a long time.

Table of Contents

The Problem

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',
}}

I built this UI with v0 and extracted the images from a screenshot using o4-mini with toolcalling.
}

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:

The Solution: more mods

I ended up creating two mods with a mix of OpenAI Codex (the cli), Claude Code, and Cursor to help me out:

  • TileFinder (GitHub, CurseForge): Search, filter, and easily locate TileEntities (fancy/complex blocks) from all mods
  • Unnamed Project Management mod: a multiplayer mod project management mod

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.

TileFinder

{

Click to toggle

You can bind a keybind to open a UI to filter and search for specific blocks in your vicinity:

Screenshot of the TileFinder UI grouped by mod
Blocks within 32 blocks from me grouped by their mod

When you select an item, a (fully configurable) path is drawn to it.

TileFinder

}

Unnamed Project Management mod

{

Click to toggle

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.

}

Learnings for vibecoding

  • o3 is a great agentic model. It's price reduction makes it cheaper than Sonnet 4.
  • Sonnet did a good job when I tried it too. But I felt like it generally did a worse job using tools to e.g. explore the code base. The exception is when inside Claude Code, Sonnet did great work.
  • Write rules files when you see common mistakes. I noticed all models kept grepping the codebase and getting back a lot of built/unnecessary files, which unnecesarily takes up context. Adding rules files (files automatically injected into the context) lets you address these issues.
  • OpenAI Codex as a CLI is good-not-great. I didn't try their web offering, which is probably better, but I'm not sure you can setup an entire environment for it to have a real agentic feedback loop.
  • I could have made every mod in its entirety with any of the tools I tried. But perhaps obviously Cursor + o3 was by far the most effective combo for me as a developer.

Learnings for developing

  • Errors need to suggest fixes.
    • If there is no one clear fix, they can suggest multiple solutions, or explain in plain-text why the issue occurred. They should do that second part anyways
  • More (AI) tooling is needed around targeting specific package versions.
    • Minecraft is tricky because it has many versions with many breaking changes, and the LLMs are trained across all the versions. I think one reason I had a lot of success is I was targeting a fairly old version of Minecraft known for its mods (1.12.2).
    • I've had this same issue at work with npm packages and libraries. Tools/MCPs like Context7 can help substantially with this.

Both mods (total) cost me about $8 in usage.

]]>
https://maxleiter.com/blog/vibecoding-minecraft-mods https://maxleiter.com/blog/vibecoding-minecraft-mods Sat, 21 Jun 2025 00:00:00 GMT
<![CDATA[Implementing Notion style URLs]]> I hate unrecognizable URLs. If I want to go back to a specific document or page, I should be able
to type the name of it in the address bar and find it in my browser history. Notion is particularly good at this,
and v0 was particularly bad at it (for a while), so this is what I did to fix v0.

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:

Screenshot of Firefox Suggestion matching the URL when I typed Team Roadmap

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:

  • You need to ensure the canonical URL is up-to-date
  • The client needs to wind up on the most recent URL, otherwise crawlers like Google can index "fake" paths
  • Be mindful of your previous URLs and ensure they don't conflict with the logic for adding/parsing the slug

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:

  • Updating the canonical URL
  • Implementing a way to redirect users to the updated URL.
    • If you do this on the server, you can serve a 301 redirect

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

]]>
https://maxleiter.com/blog/sluggified-urls https://maxleiter.com/blog/sluggified-urls Thu, 19 Jun 2025 00:00:00 GMT
<![CDATA[Introducing the v0 composite model family]]>
Read on vercel.com]]>
https://vercel.com/blog/v0-composite-model-family https://vercel.com/blog/v0-composite-model-family Sun, 01 Jun 2025 00:00:00 GMT
<![CDATA[Reduce Cognitive Load]]> The AI team at Vercel has been growing; what was five people a year ago is almost 15.
In most ways, this has been fantastic. But it's been a challenge, too. More people are reading my code, and I'm reading
theirs. A lot of time can be spent just trying to understand what's going on, even if it's a code base you're familiar with, to no fault of the author.

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.

1. Incremental PRs

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.

2. Coding style

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?

3. Comments

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.

]]>
https://maxleiter.com/blog/reduce-cognitive-load https://maxleiter.com/blog/reduce-cognitive-load Wed, 28 Aug 2024 00:00:00 GMT
<![CDATA[Never think about branch names again]]> I recommend swapping out `max` to basically anything else.

  #!/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.

]]>
https://maxleiter.com/notes/git-checkout-script https://maxleiter.com/notes/git-checkout-script Mon, 19 Aug 2024 00:00:00 GMT
<![CDATA[Updating JSONB fields with Drizzle]]> I recently made a jsonb column in a postgres database and it took me a bit
to figure out how to update a single value without overwriting the entire
field. If you know a better way please let me know.

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))
}
]]>
https://maxleiter.com/notes/updating-jsonb-drizzle https://maxleiter.com/notes/updating-jsonb-drizzle Thu, 11 Jul 2024 00:00:00 GMT
<![CDATA[Ship something every day]]> Edit: A better title would've been "commit every day that you work". I don't mean you should work on weekends or not take time off, and
whatever you work on doesn't need to "ship to prod".


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:

  • The dopamine rush of your code being shipped
  • Your team sees you're working
    • There's more to this than just performance reviews; with remote work, it's easy for you and co-workers to feel isolated.
  • It encourages incremental work. Your future self and co-workers will thank you
  • Your git commit streak looks good
    • Yes, in an ideal world this doesn't matter. But I'm sure people like recruiters look at GitHub profiles, and an empty page isn't a great look. This is a benefit of the habit, not necessarily a reason to start it.
  • The satisfaction and mental benefits of getting something done.

People mentioned it seems like I'm advocating for you to push _code_ every day. My point is that you should contribute _something_: docs, triage, whatever. For me, it's usually code. ]]>
https://maxleiter.com/blog/ship-every-day https://maxleiter.com/blog/ship-every-day Mon, 10 Jun 2024 00:00:00 GMT
<![CDATA[Set your default directory in VS Code's open dialog]]> This is my workflow for opening a project in VS Code:

  1. Open an empty window of VS Code
  2. Hit cmd+O
  3. Navigate to my `~/Documents/...` directory
  4. Hit enter

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).

VS Code settings

Hope this helps.

]]>
https://maxleiter.com/notes/vscode-default-dir https://maxleiter.com/notes/vscode-default-dir Thu, 06 Jun 2024 00:00:00 GMT
<![CDATA[gemini.sh script]]> As of late 2024/early 2025, I recommend using the great repomix tool: https://github.com/yamadashy/repomix

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
]]>
https://maxleiter.com/notes/gemini-script https://maxleiter.com/notes/gemini-script Sun, 14 Apr 2024 00:00:00 GMT
<![CDATA[Sending a Slack message with fetch]]> I like using Slack bots for things like alerts and notifications and
have needed this snippet enough to leave it here.

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(),
})
]]>
https://maxleiter.com/notes/slack-message-fetch https://maxleiter.com/notes/slack-message-fetch Sat, 30 Mar 2024 00:00:00 GMT
<![CDATA[SWR for more than fetching]]> SWR is a great library for data fetching in React.
It looks like this:

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.

]]>
https://maxleiter.com/notes/swr-state https://maxleiter.com/notes/swr-state Sat, 30 Mar 2024 00:00:00 GMT
<![CDATA[Loading wasm files on Vercel Edge and Node.js runtimes]]> Loading wasm on the Vercel Edge runtime is easy. You need to do a little more work
to do the same on Node.js.

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)
  }
}
]]>
https://maxleiter.com/notes/wasm-edge-and-node https://maxleiter.com/notes/wasm-edge-and-node Sat, 30 Mar 2024 00:00:00 GMT
<![CDATA[watch.sh]]> A simple bash script for macOS that runs a command
and displays a notification when the output of the command changes.

#!/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:

  1. Save the script to a file (e.g., `watch` in `/usr/local/bin`)
  2. make it executable (`chmod +x watch.sh`)
  3. Run it with the command you want to watch as the argument. You can optionally specify a sleep time in seconds as the second argument (defaults to 3).
]]>
https://maxleiter.com/notes/watch.sh https://maxleiter.com/notes/watch.sh Sat, 30 Mar 2024 00:00:00 GMT
<![CDATA[Introducing AI SDK 3.0 with Generative UI support]]>
Read on vercel.com]]>
https://vercel.com/blog/ai-sdk-3-generative-ui https://vercel.com/blog/ai-sdk-3-generative-ui Fri, 01 Mar 2024 00:00:00 GMT
<![CDATA[Some tips for working with Next.js 14]]> I've been working with the Next.js App Router for a while now, and wanted to share some of the patterns and snippets I've found useful and keep referring to.
My goal is for this post to be a living document, but I haven't been great about writing much so we'll see about that 🤷‍♂️.

I'm assuming you have some familiarity with the App Router. If you don't, I recommend you check out my older post: [Build a blog with Next.js 13](/blog/build-a-blog-with-nextjs-13).

Table of Contents

SWR for state management

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.

For Modals and Dialogs

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

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.

Validation

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);

Error handling

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).

Gotcha: Stable references with React.cache

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].

You don't need router.refresh

router.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.

]]>
https://maxleiter.com/blog/how-i-work-with-nextjs-14 https://maxleiter.com/blog/how-i-work-with-nextjs-14 Mon, 19 Feb 2024 00:00:00 GMT
<![CDATA[Transcribing with AWS Textract]]> My family has (in my opinion) a very cool written history of our family tree on my father's side.
Recently, my grandfather found a renewed interest in the book and decided to digitize and update it.
He took it to a local print shop and had them scan it to a PDF (it's 60+ pages), which resulted in scans like this:

typewriter scan

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:

On or about 1788 la a san22 tom of Strollaka Calissim
wife and saree sons z-Barl, • Mappo, sad Losdeena.

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:

On of about 1768 in © omehl town of Strelisks Galitsia family by the mame of Wolf sin Moerdessi was living with his Wife snd three sons ;~- Berl, Lippe, and Mordecad,

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:

Click to expand
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:

On oz about 1788 in a small town of Stroliska Calitais a family by the name of Welf sin Mordaoas was living with his Wife and three sons :- Barl, Lippe, and Mordecai.

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!

]]>
https://maxleiter.com/blog/transcribing-typewriter https://maxleiter.com/blog/transcribing-typewriter Sun, 10 Sep 2023 00:00:00 GMT
<![CDATA[Introducing the Vercel AI SDK]]>
Read on vercel.com]]>
https://vercel.com/blog/introducing-the-vercel-ai-sdk https://vercel.com/blog/introducing-the-vercel-ai-sdk Thu, 15 Jun 2023 00:00:00 GMT
<![CDATA[Why your website's fonts might be larger than intended]]> Someone recently reported that the font-size in some of my code blocks on my build-your-own Next.js blog post was too large. That's interesting because I use React and the code blocks are the same component. So why would the font-size be different between them?

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.

]]>
https://maxleiter.com/blog/mobile-browsers-resizing-font https://maxleiter.com/blog/mobile-browsers-resizing-font Fri, 09 Jun 2023 00:00:00 GMT
<![CDATA[Nintype is still the best iOS keyboard]]> Describing Nintype is a challenge. Here's a the demo video from it's launch back in 2014:

"the true touch typing experience"

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:

Swipe and type

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.

Customization

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:

Nintype settings showing
<img
src="/blog/nintype/settings-1.png"
alt="Nintype keyboard showing"
style={{ maxWidth: '350px', margin: 'auto', marginTop: 'var(--gap)' }}
/>

Autospace

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 / shortcuts

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.

It still works today

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.

]]>
https://maxleiter.com/blog/nintype https://maxleiter.com/blog/nintype Mon, 22 May 2023 00:00:00 GMT
<![CDATA[Building a blog with Next.js 15 and React Server Components]]> I've been tinkering with this website and the Next.js 13 App Router for a while now,
and it's been a great experience, especially in the last few months. But I've seen some confusion around
how to use the new features and React Server Components, so I wrote up an outline on building this website.

{' '} This post has been updated for [Next.js 15.](https://nextjs.org/blog/next-15){' '}

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:

  • Act as documentation for Next.js or React
  • Be a 100% code-complete tutorial. You'll need to fill in some gaps yourself.

And here's what it will do:

  • Show you real-world examples involving React Server Components and the App Router.
  • Guide you in spinning up your own blog with Next.js 13 and React Server Components with great SEO and performance.
    • I say static first because while you can opt into dynamic rendering everything presented here can result in a fully static no-JavaScript site.
  • Demonstrate how to enable writing in markdown with MDX and next-mdx-remote/rsc
    • We'll use Bright for server-side syntax highlighting.
  • Act as a launching point for your own experimentation and exploration.

Now that that's out of the way:

Table of Contents

Set up the project

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
If you want to migrate an existing project, refer to the [Next.js installation documentation](https://beta.nextjs.org/docs/installation).

Hit `y` (or `n`, I'm not your boss) a few times to complete the wizard.

File structure

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 }}> icon, but the other pages do.
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:

You can click any of the files in a file tree to view the source code on GitHub.

A blog needs posts

Routing with dynamic segments

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?

Fetching and rendering markdown

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.

Fetching your posts

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.

Rendering your posts

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
}
{"How I get the width and height of an image"}

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
![alt text](/image.png?width=500&height=400)

Syntax highlighting with Bright

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 }))
}
If you think it's bad that we call \`getPost\` _and_ \`getPosts()\`, remember the we wrapped \`getPosts\` in \`cache\`. \`getPost\` just calls \`getPosts\`, so we're not making any unnecessary requests to the filesystem (or wherever you're getting your posts from).

SEO

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.

Metadata API

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',
  },
}

Sitemap support (`sitemap.js`)

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]
}

Generating an RSS Feed

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()

Deployment

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.

Wrapping up

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).

]]>
https://maxleiter.com/blog/build-a-blog-with-nextjs-13 https://maxleiter.com/blog/build-a-blog-with-nextjs-13 Sun, 16 Apr 2023 00:00:00 GMT
<![CDATA[Improving the accessibility of our Next.js site]]>
Read on vercel.com]]>
https://vercel.com/blog/improving-the-accessibility-of-our-nextjs-site https://vercel.com/blog/improving-the-accessibility-of-our-nextjs-site Fri, 30 Sep 2022 00:00:00 GMT
<![CDATA[The Node ecosystem (still) has tooling problems]]> I recently published a small npm package containing a Vue library. The package is written in TypeScript powered by Vite, which I (wrongly) assumed would mediate most of my woes.

Obi Wan gif of him sarcastically saying

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:

Unix Magic poster, a wizard crafting a potion surrounded by ingredients named after Unix tools

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.

]]>
https://maxleiter.com/blog/node-has-tooling-problems https://maxleiter.com/blog/node-has-tooling-problems Sat, 30 Jul 2022 00:00:00 GMT
<![CDATA[Live updating page views with Supabase and Next.js]]> This post was written for the (now stable) beta Next.js middleware. If you want your own analytics, I recommend looking at{' '} Vercel's solution.

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

Motivations

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.

How

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.

Supabase: Setting up the Database and Realtime

  1. Register an account with Supabase here: https://app.supabase.io/

  2. Create a Supabase project and fill out the new project form.

    A screenshot of the Supabase page with the New Project button clicked

    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...

    A screenshot of the Supabase new project page

  3. 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:
    A screenshot of the new table field showing 4 columns: an int8 ID, a text slug, an int8 views with a default value of 1, and an updated_at date column with a default value of
    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`.

  4. 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!

  5. 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.
    A screenshot of the function creation view

    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!

  6. 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:
    A screenshot of the empty replication page, showing prepopulated a `supabase_realtime` row

  7. Click the `0 tables` button the `Source` column and toggle the new table you created (I called mine `analytics`):
    The analytics table has been enabled in the replication page

  8. 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.

Next.js: Adding server-side analytics

  1. 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.

  2. Add the `@supabase/supabase-js` package from npm to your project:

    yarn add @supabase/supabase-js
    

    or

    npm install @supabase/supabase-js
    
  3. 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.

  4. 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.

  5. 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()
    }
    
  6. 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.

Next.js: Adding a live-updating view counter

  1. Now we can finally add our updating view count. In your React component, import your public Supabase client.

    import supabase from '@lib/supabase/public'
    
  2. 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 (...)
    }
    
  3. 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>
      )
    }
    
  4. 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.

Next steps

There's quite a lot you can do from here, but here are some recommendations:

  1. Only allow unique visitors, perhaps by storing IP hashes or using localStorage. This could probably be accomplished with Supabase Edge functions?
  2. Build a visualization page for interacting and querying the results
  3. Expand your analytics to include things like keeping track of the `referer` value.
  4. Ignore certain user agents to reflect a more accurate view count
]]>
https://maxleiter.com/blog/supabase-next-analytics https://maxleiter.com/blog/supabase-next-analytics Sun, 15 May 2022 00:00:00 GMT
<![CDATA[Internet Explorer or CSS3?]]> I was watching Reactathon 2022 this weekend and enjoyed the Syntax fm live session. They did a great audience interaction bit with quizzing them about what a particular piece of CSS did.

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.

]]>
https://maxleiter.com/blog/ie-or-css3 https://maxleiter.com/blog/ie-or-css3 Wed, 11 May 2022 00:00:00 GMT
<![CDATA[Easy ways to improve your websites accessibility and performance]]> Below is a short list I wrote up for people who are comfortable building websites (with any technology) but want to improve their work, whether that be in terms of loading speeds, software quality, or accessibility.

TL;DR:

WAVE

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.
Screenshot of the WAVE tool being used on webaim.org

Compress your images

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!

Embrace semantic HTML

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.

Don’t use JavaScript to replace browser controls

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.

Invest in learning your developer tools

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.

]]>
https://maxleiter.com/blog/easy-site-improvements https://maxleiter.com/blog/easy-site-improvements Sat, 23 Apr 2022 00:00:00 GMT
<![CDATA[How hackers* run their sites]]> * hackers: users of hacker news

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.

The stats:

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.

Some fun ones:

  • 4 sites offer the `onion-location` response header
  • One `"x-nananana": "Batcache",` response header
  • One site is behind `Apache/2.4.7`, which was released in 2013 and has multiple CVEs
    • This is also the only site to advertise `mod_perl`, so do with that what you may.

The scraped stuff:

  • Contains JavaScript: 567 (81%)
  • Github Pages: 146 (21%)
  • Cloudflare: 141 (20%)
  • Nginx: 114 (16%)
  • Netlify: 83 (11%)
  • Apache: 70 (10%)
  • Vercel: 50 (7%)
  • Bootstrap: 45 (6.5%)
  • Gatsby: 30 (4.3%)
  • Nextjs: 28 (4%)
  • Amazon S3: 27 (3.9%)
  • Wordpress: 22 (3.2%)
  • Cloudfront: 17 (2.5%)
  • Express: 16 (2.3%)
  • PHP: 14 (2%)
  • Caddy: 9 (1.3%)
  • Litespeed: 8 (1.5%)
  • Open Resty: 8 (1.5%)
  • Google App Engine: 3 (0.43%)
  • FlyIO: 3 (0.43%)
  • MicorosftIIS: 3 (0.43%)
  • Drupal: 2 (0.29%)
  • Tailwind: 2 (0.29%)
  • Lighttpd: 2 (0.29%)
  • Godlighty: 1 (0.14%)
  • Perl: 1 (0.14%)
  • Neocities: 1 (0.14%)
  • Asp.NET: 1 (0.14%)
  • Gatsby Cloud: 1 (0.14%)
  • Openbsd Httpd: 1 (0.14%)

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.

]]>
https://maxleiter.com/blog/hacker-sites https://maxleiter.com/blog/hacker-sites Thu, 07 Apr 2022 00:00:00 GMT
<![CDATA[Introducing Drift]]> View this post on Drift

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):

A page showing the ability to enter a post title and two textboxes: file name and file content. You can also click 'add another file' or 'Create', which is a drop-down with multiple privacy options for creating posts.

And here's a screenshot of viewing a post, which you can visit in your browser here:

A page showing a post title, the ability to download all files a ZIP archive, a dropdown to jump to specific files, and a file titled 'webpack.config.js' with javascript highlighted code.

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.

]]>
https://maxleiter.com/blog/introducing-drift https://maxleiter.com/blog/introducing-drift Sat, 26 Mar 2022 00:00:00 GMT
<![CDATA[How to start contributing to open-source projects]]> One of the most common questions I'm asked by my computer science friends and students is how they can contribute to an open-source project. In order for me to organize and share my thoughts I thought I'd write something 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:

  • Firefox (really, it's (in some cases) as fast as Chrome now! Plus it helps you escape the Alphabet.)
  • GIMP (GNU Image Manipulator Program, a free version of Photoshop.)
  • VS Code (note that most of it is open-souce, but parts are not.)
    • See VSCodium if you want a VS Code fork without Microsoft telemetry and licensing

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.

The steps

  1. Find a project and feature or bug to work on: find one from experience or on their issue tracker/mailing list. Targeting a bug that bothers you or a feature you personally want is ideal due to the motivation aspect mentioned above. If the project is on GitHub these may be in the Milestone or Discussion pages. GitHub Explore filtered to languages you know or are interested in learning is a great place to start.
  2. Search for prior work or discussions: it's important your changes align with the maintainers vision, and you should double check that no one else is working on it. If they are or previously have, you can offer to team-up or ask for their advice.
  3. Familiarize yourself with the code base: depending on the size, it may not be feasible or sensible for you to grok the entire code base. Instead, try to learn just the parts you need. Feel free to ask the maintainers after searching the documentation and using the below tips if you need help:
    • Read the README and CONTRIBUTING files, if they both exist[^2]
    • Use a package explorer or the Unix `find` utility to find directories and files of interest
    • Use `git grep` to find identifiable or interesting messages/logs/strings in the code (an error alert's text, for example)
    • Use `git log` to explore commits
    • Use `git blame` to find out who to email or contact with questions
      • You can also check the README or look for a COMMUNITY file to find where to ask
  4. Get crackin': Work on the task, reaching out to the maintainers and/or community for help if necessary. Always be courteous, patient, and as clear as possible. Code samples and reproducible cases are king.
  5. Submit your work: Most likely, submitting your completed work will be done via a mailing list or over a code forge like GitHub and BitBucket. Refer to their documentation and the project's documentation to determine how to submit your changes.
  6. Wait, Fix, Repeat: It's likely you'll need to address some comments and make some changes to your initial work. That's okay! Address their comments and send it back to them. Few things feel worse than wasting a reviewers time (especially a volunteer like in many open-source projects), so be sure you self-review your code like you (hopefully) would an English paper.[^3]

A quick example:

...which you can read more about in my dedicated post.

  1. I wanted ambient light support on my Surface Pro 3 running Linux
  2. I searched Google for information and luckily found this issue and repo:
    Image of the linked github issue
  3. I searched the LKML for previous examples of minor driver adjustments and joined the #linux-surface IRC channel to talk with the other contributors to hear their ideas.
  4. After a few hours and with some help from IRC I determined how to have the device registered using the existing driver and pushed my code to GitHub.
  5. Once the code was reviewed by a few members of linux-surface I submit my patch set to LKML.
  6. It was released in Linux 5.11 🎉

In short

  • Read the documentation
  • Find an interesting problem to tackle that motivates you
  • Discover and grow comfortable with your tools for grepping and exploring unfamiliar code bases
  • Don't be afraid to reach out to the maintainers and community; develop communication skills

[^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.

]]>
https://maxleiter.com/blog/contributing-to-oss https://maxleiter.com/blog/contributing-to-oss Tue, 25 Jan 2022 00:00:00 GMT
<![CDATA[Pin your npm/yarn dependencies]]> Today, the open-source maintainer Marak intentionally bricked two popular JavaScript libraries (with ~25 total million weekly downloads) in such a way that they broke dependents software. There's nothing that npm or any other public registry could've done to have prevented this; the release was indistinguishable from a regular update, unless you looked at the code. This is not the first time an npm dependency has gone rogue and caused havoc due to supply chain problems, and it certainly won't be the last. And so the defensive role falls to you, the developer.

Pinning dependencies

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.

What about lockfiles?

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.

In conclusion

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.

]]>
https://maxleiter.com/blog/pin-dependencies https://maxleiter.com/blog/pin-dependencies Sun, 09 Jan 2022 00:00:00 GMT
<![CDATA[My macOS programs and setup]]> In the off-chance you're reading this today, happy new year! If you're reading this later, I hope $CURRENT_YEAR is treating you well.

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!

  • Firefox Nightly
  • VisualStudio Code
  • iTerm 2
  • BetterTouchTool allows advanced customization of the touchbar ($9 after 45 days)
  • Homebrew is a package manager
    • Handles installing Xcode developer tools
  • Rectangle lets you resize and snap windows with your keyboard
    Image of the Rectangle settings showing it has many options
  • Fish is user-friendly shell (seriously, I hate zsh/bash now)
  • NextDNS is a secure DNS service (I downloaded the macOS client from the App Store)
  • LastPass is my password manager, but I'm currently looking to start self-hosting.
  • Insomnia is my favorite REST client
  • IINA is a nice media player
  • VIA Configurator configures my keyboard
  • Lagrange for viewing Gemini articles
  • yarn is my node package manager of choice
  • MySQL Workbench for managing my few MySQL servers
  • TeXMaker for the rare times I need to write LaTeX
  • I'm going to try and use the Slack and Zoom browser versions
  • Qemu: `brew install qemu`
  • thefuck: `brew install thefuck`, command line correction tool
    • Add `thefuck --alias | source` to `~/.config/fish/config.fish`
  • imagemagick: `brew install imagemagick`
  • ffmpeg: `brew install ffmpeg`
]]>
https://maxleiter.com/notes/mac-setup https://maxleiter.com/notes/mac-setup Fri, 31 Dec 2021 00:00:00 GMT
<![CDATA[useMousetrap()]]> I've been messing around with a React implementation of The Lounge lately (for no real reason except that I can) and wanted a functional way to use Mousetrap, which TL already uses. The below hook let me replace the Vue use of Mousetrap (which was pretty much vanilla JS) in a React-like fashion. For bonus typing, add `@types/mousetrap` to your project.

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
]]>
https://maxleiter.com/notes/useMousetrap https://maxleiter.com/notes/useMousetrap Tue, 28 Dec 2021 00:00:00 GMT
<![CDATA[Creeper Host API wrapper in TypeScript]]> I've had a server on Creeper Host for around eight years now, and have used it for small side projects and hackathons. Lately, I've been trying to automate more of my life and discovered that the API their in-house control panel uses is available for general use. I'm making this post in case someone else ever wants to interact with their API via JavaScript. Maybe someday I'll create a proper repository, but for now my wrapper is incomplete and it's easier to just include here. It was also a good chance to write some TypeScript from scratch, something I haven't had the opportunty to do very much of.

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:

Screenshot of the Creeper Host control panel with a blue 'Generate Key' button

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.

]]>
https://maxleiter.com/blog/creeperhost-api https://maxleiter.com/blog/creeperhost-api Mon, 22 Nov 2021 00:00:00 GMT
<![CDATA[run-pr.sh]]> Today I found myself once again trying to run a pull request that was created in a different GitHub repository. If it was in the same repo, it would be a simple `git checkout`, however because GitHub insisted on the creation of "forks" I now need to go through the extra steps of fetching that specific ref. Here's what I do:

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).

]]>
https://maxleiter.com/notes/run-pr https://maxleiter.com/notes/run-pr Thu, 18 Nov 2021 00:00:00 GMT
<![CDATA[Impressions of the Framework Laptop]]> I received my Framework Laptop DIY edition a few days ago and have been setting it up software-wise. I have it running Manjaro Linux with the Sway window manager. This is my first time using a Wayland desktop, and oh has it been a learning experience. This post will be largely about the hardware, but I plan to make one about the software once I'm more happy with my setup.

Here's what I got:

  • Framework Laptop DIY Edition
  • 16GB (2 x 8GB) DDR4-3200
  • Intel® Wi-Fi 6E AX210 No vPro
  • 1TB - WD_BLACK™ SN850 NVMe™
  • USB-A Expansion Card
  • USB-C Expansion Card x2
  • HDMI Expansion Card

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.

The Good

  • Well crafted body. I was not expecting a premium build quality from a startup trying to create an upgradeable laptop, but wow they excelled.
  • It's really light. It's just 2.8 pounds, compared to my 13" Macbook Pro's 3.02. You wouldn't think that's a big difference, and maybe you wouldn't notice, but I sure did.
  • A solid keyboard. Most non-Mac laptops I've tried have awful keyboards. It's not as rigid as an Apple non-butterfly board, but it's close. Definitly usable.
  • Modular expansion ports (via pass-through to USB-C) are a fantastic idea. I can't wait for 3rd party ones to be released.
  • Hardware switches for the camera and microphone.
  • Beautiful glossy screen. I haven't used it extensively outside, but it's worked well enough for me (especially at full brightness).

The Bad

  • There are only 3 preset levels of keyboard backlight you can switch between: off, on, and half. Perhaps my Mac spoiled me, but I'm used to having more fine-tuned control over it.
  • Installing the wifi card was difficult. It took me twice as long to install the WiFi as it did to setup the rest of the computer. They acknowledge this in their installation instructions, but othes seem to also be having problems:
    Image of the Framework setup guide comments showing 6 people also having difficulty
  • Removing expansion ports requires far more force than installing them. Feels a little awkward.
  • 12 seconds of pressing the power button to hard reboot. Not that big of a deal, but definitely feels way too long.
  • Why does the screen need to swing open 185°?

The Ugly

  • This isn't necessarily due to the hardware, but the battery life on Linux currently requires a little finessing to be reasonable. I'm sitting at ~4h of moderate use after tweaking numerous configs and following multiple different sets of instructions from around the internet. This isn't good enough for me to take on campus for class, but it's great for doing work around the house.
  • Lacks the ability to set charging thresholds. It's being worked on, but at the moment it doesn't seem possible.
  • A strange 2256x1504 resolution. Why couldn't they have chosen something more standard? Although I think my issues with this are mostly due to Linux / Wayland. Maybe I'll get used to 3:2.
  • It requires a little extra care to open the laptop without sliding it (perhaps due to its light weight).

In conclusion

I'll let my Tweet from a few days ago sum it up:

![Image of the Framework setup guide comments showing 6 people also having difficulty](/blog/framework/twitter.png)

]]>
https://maxleiter.com/blog/framework https://maxleiter.com/blog/framework Wed, 03 Nov 2021 00:00:00 GMT
<![CDATA[Adding ambient light support to Linux and GNOME]]> A couple of a weeks ago I picked up a Surface Pro 7 (i3 1.2 mhz) at a Black Friday sale. I love making devices run software they shouldn't, so I got to work dual-booting Linux on it. To my surprise, almost everything worked with the default Fedora 33 kernel, not including the touchscreen, the ambient light sensor, and the camera. After compiling and loading the great linux-surface kernel the touchscreen started to work but I found a GitHub issue someone had made detailing that the ambient light sensor (a MSHW0184) isn't detected. This normally wouldn't matter a lot, but I'd discovered that GNOME supported adjusting the screen brightness based on the ambient light through iio-sensor-proxy.

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

]]>
https://maxleiter.com/blog/MSHW0184 https://maxleiter.com/blog/MSHW0184 Tue, 15 Dec 2020 00:00:00 GMT
<![CDATA[New edge dev infrastructure]]>
Read on vercel.com]]>
https://vercel.com/blog/new-edge-dev-infrastructure https://vercel.com/blog/new-edge-dev-infrastructure Tue, 21 Jul 2020 00:00:00 GMT
<![CDATA[X11 on iOS]]> While the content below may be useful to some, the Cydia repository is no longer available due to the maintaince overhead and my loss of interest. If you still want X11 on iOS/iPadOS, check out the Procursus project.

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.

Image of X11 on iOS [Image of programs running on device.](/blog/X11/onipad.jpg)

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.)

How it works

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.

Why

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.

Current Features

  • X11 on iOS via Xvnc
  • Working window managers (jwm / twm / fluxbox)
  • Text editor (adie)
  • Image editor (azpainter)
  • OpenGL (via Mesa)
  • Loads of other libraries, tools, and applications.

Building instructions

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:

  • Gettext
  • Glib
  • libffi
  • libxml2
  • m4
  • OpenSSL
  • perl
  • PkgConfig
  • libiconv
  • Python @ 3.7
  • readline
  • zlib
  • clang-10
  • Darwin CC Tools
  • Bison
  • Flex
  • libstdc++ (C++ Standard Library symlink)
  • automake
  • autoconf
  • ninja
  • libpng
  • gperf

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 \. Also, remove the -z, now characters from /unix/xserver/hw/vnc/Makefile.in, where it's assigning libvnc_la_LDFLAGS.
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

]]>
https://maxleiter.com/blog/X11 https://maxleiter.com/blog/X11 Sun, 15 Dec 2019 00:00:00 GMT