Table of Contents

Punctilio for meticulous typography

punctilio (n.): precise observance of formalities.

Pretty good at making your text pretty. The most feature-complete and reliable English typography package.  punctilio transforms plain ascii into typographically correct Unicode, even across html element boundaries. Try it live at  turntrout.com/punctilio.

Smart quotes · Em / en dashes · Ellipses · Math symbols · Legal symbols · Arrows · Primes · Fractions · Superscripts · Ligatures · Non-breaking spaces · html-aware · Markdown support · Bri’ish, German, and French localisation support

import { transform } from "punctilio";
 
transform(`"It's a beautiful thing, the destruction of words..." -- 1984`);
// → “It’s a beautiful thing, the destruction of words…”—1984

punctilio accepts three input formats: text, Markdown, and html. Use it as a library, a CLI,pre-commit hook, or⁠—for a zero-friction install in any project that already runs Prettier⁠—a Prettier plugin.

npm install punctilio

This website

I ’ve made 6,690 commits. That’s over halfway to being over 9,000!

This site is one of my most heartfelt works of art. I’ve passionately optimized its design while obsessively testing⁠—for example, 100% TypeScript branch coverage, 100% Python line coverage, and hundreds of visual regression tests.

I open source my website infrastructure and article edit histories at  alexander-turner/TurnTrout.com. I license the repository under CC by-sa 4.0, which means you can share and adapt the site as long as you provide attribution and distribute any derivative works under the same license.

You can locally serve the site by running:

SITE_DIR=/tmp/TurnTrout.com
git clone https://github.com/alexander-turner/TurnTrout.com.git "$SITE_DIR" --depth 1
cd "$SITE_DIR"
yes | pnpm install --frozen-lockfile
pnpm dev

Automatic alt text generation

Install with  pip install alt-text-llm.

When I started writing in 2018, I didn’t include alt text. Over the years, over 500 un-alt’ed images piled up. These (mostly) aren’t simple images of geese or sunsets. Most of my images are technical, from graphs of experimental results to hand-drawn AI alignment comics. Describing these assets was a major slog, so I turned to automation.

To implement accessibility best practices, I needed alt text that didn’t describe the image so much as communicate the information the image is supposed to communicate. None of the scattershot AI projects I found met the bar, so I wrote my own package.

alt-text-llm is an AI-powered tool for generating and managing alt text in Markdown files. Originally developed for this website,  alt-text-llm streamlines the process of making web content accessible. The package detects assets missing alt text, suggests context-aware descriptions, and provides an interactive reviewing interface in the terminal.

A labeled diagram of the labeling pipeline.

Generating alt text for maze diagrams from Understanding and Controlling a Maze-solving Policy Network. alt-text-llm displays the surrounding text (above the image), the image itself in the terminal using  imgcat, and the llm-generated alt suggestion. The user interactively edits or approves the text.

In the end, I generated over 550 high-quality alt-text suggestions for about $12.50 using Gemini 2.5 Pro. With  alt-text-llm, I addressed hundreds and hundreds of alt-less images: detecting them; describing them; reviewing them; and lastly applying my finalized alts to the original Markdown files.  turntrout.com is now friendlier to the millions of people who browse the web with the help of screen readers.

If you want to improve accessibility for your content, go ahead and check out my repository!

Protect datasets from scrapers

Install with  pip install easy-dataset-share.

I helped fund this project. Here’s the introduction to an article I wrote:

Dataset contamination is bad for several reasons. Most obviously, when benchmarks are included in AI training data, those benchmarks no longer measure generalization⁠—the AI may have been directly taught the answers. Even more concerningly, if your data promote negative “stereotypes” about AIs, they might become self-fulfilling prophecies, training future models to exhibit those same behaviors.

In the Claude 4 system card, Anthropic revealed that approximately 250,000 transcripts from their alignment faking paper had been scraped from the public web and included in their pretraining data. This caused an early model to hallucinate details from the paper’s fictional scenarios, forcing Anthropic to implement unique mitigations. Speculatively, this kind of misalignment data could degrade the alignment of any models trained thereafter.1

Data scraping practices are a serious problem. The tool we are currently releasing will not stop state-of-the-art actors. Since I wanted to at least mitigate the problem, I put out a bounty for a simple, open source tool to harden data against scraping. The tool is now ready:  easy-dataset-share. In less than 30 minutes and at a cost of $0, you can deploy a download portal with basic protections against scrapers, serving a canary-tagged dataset with modest protections against AI training.

easy-dataset-share will not stop sophisticated scrapers

Sophisticated scraping operations can bypass Cloudflare Turnstile for about $0.001 cents per trial (via e.g. CapSolver). The  robots.txt and Terms of Service are not technically binding and rely on the good faith of the user, although the ToS does provide limited legal deterrence. Canary strings can be stripped from documents. Overall, this tool is just a first step towards mitigating dataset contamination. We later discuss improvements which might protect against sophisticated actors.

Automated setup

One command to set up your shell, editor, and secret management.

My  .dotfiles repository provides comprehensive development environment setup. With this command, I quickly personalize any shell⁠—even if I’m just visiting with  ssh for a few hours.

  1. Fish shell with autocomplete, syntax highlighting, and the  tide theme,
  2. neovim via LazyVim, providing a full ide experience,
  3. tmux with automatic session saving and restoration,
  4. envchain for hardware-encrypted secret management via macOS Secure Enclave or Linux gnome-keyring⁠—no more plaintext api keys in configuration files,
  5. Open source AI tool setup,
  6. autojump for quick directory navigation,
  7. Reversible file deletion by default via  trash-put instead of  rm,
  8. git aliases and other productivity shortcuts, and⁠—drum roll⁠—
  9. goosesay, because every terminal needs more geese.
  ______________________________________
 / Find out just what any people will   \
 | quietly submit to and you have the   |
 | exact measure of the injustice and   |
 | wrong which will be imposed on them. |
 \ --- Frederick Douglass               /
  --------------------------------------
    \
     \
      \     ___
          .´   ""-⹁
      _.-´)  e  _  '⹁
     '-===.<_.-´ '⹁  \
                   \  \
                    ;  \
                    ;   \          _
                    |    '⹁__..--"" ""-._    _.´)
                   /                     ""-´  _>
                  :                          -´/
                  ;                  .__<   __)
                   \    '._      .__.-'   .-´
                    '⹁_    '-⹁__.-´      /
                       '-⹁__/    ⹁    _.´
                      ____< /'⹁__/_.""
                    .´.----´  | |
                  .´ /        | |
                 ´´-/      ___| ;
                          <_    /
                            `.'´

Each time I open the  fish shell, a rainbow goose blurts out an interesting phrase. I spent several hours to achieve this modern luxury.

if status is-interactive
    fortune 5% computers 5% linuxcookie 2% startrek 88% wisdom | cowsay -f ~/.dotfiles/apps/goose.cow | lolcat -S 6
end

The way this works is that:

  1. I sample a saying by calling the  fortune command,
  2. I pipe the saying into  goosesay (my variant of the cow in the original  cowsay),
  3. The  lolcat command splays the text ’cross the rainbow.

Faster font subsetting

@turntrout/subfont is a hard fork of  Munter/subfont, which shrinks fonts to only contain the characters necessary for the content.

The original  subfont traced font usage from scratch on every page. That took almost two hours per deploy. My fork groups pages by their css and only traces one representative per group, extracting just the text from the rest (for my site: 382  5 css traces). Those remaining traces run in parallel across worker threads.

Claude Code automation template

alexander-turner/claude-automation-template packages my automation workflows into a reusable starting point for any project using Claude Code. The template is designed so that adopting repos get improvements automatically via the sync workflow⁠—fix a bug in the template, and every downstream project picks it up.

Sandbox your coding agent

When handling dangerous materials, a chemist doesn’t yolo it barehanded on the open bench. They reach through a glovebox to do their work.

Right now, you’re probably barehanding AI, tossing it into a shell onto your machine with access to your credentials. At most, you’re shielded by a single prompted “auto-mode” classifier. Doing better is hard and while there are a few papers on best practices, it’s not clear how to do it properly and you have experiments to run.

After all, what’s the chance that something bad happens?

glovebox is a sealed enclosure that only gives the agent what it needs to do your work. Tap-tap-tap  glovebox and press Enter to spin up a hardware-isolated, allowlist-firewalled microVM, employing input / output sanitization to strip injections and tricks the AI might play on you, with an AI monitor with a red-alert ability to push-notify your phone and halt the AI until you return. The goal is a minimal-friction secure experience that gets the job done.

A researcher reaches through the sealed gloves of a laboratory glove box to work with material sealed inside, the barrier keeping it contained.

Disclaimer: I’m an AI professional but not a security professional. I welcome issues and PRs.

Make your CI confess

Make your CI confess what it’s hiding. A pack of fast, offline pre-commit lints that catch two kinds of lie a green check can hide:

  • Honesty lies: the pipeline reports success while the real work failed (exit codes masked by pipes), or a required check silently never reports and the PR hangs forever.
  • Identity lies: a base image or downloaded artifact is pinned to a mutable name (a tag, a bare url), so the bytes you run aren’t provably the bytes you reviewed.

What it checks

Honesty (Tier 1, default-on)

HookFailure it prevents
check-workflow-pipefailCI went green while  pytest was crashing, because  pytest | tee log exits with  tee’s status⁠—under a  runCmd: / shell: sh / custom  bash that lacks  pipefail.
check-exit-suppressionA teardown that left a volume pinned reported success, because  cleanup || true discarded its non-zero exit while keeping its output.
check-stderr-suppressionA container launch failed with a bare non-zero and no clue why, because  docker compose up 2>/dev/null threw away the only diagnostic.
check-substitution-exit-swallowAn allowlist-building loop reported success while adding nothing, because  done < <(jq …) (or  jq … | while read) discards  jq / yq’s exit status⁠—a renamed key or malformed input makes the producer exit non-zero, the loop iterates zero times, and the fail-open goes unnoticed. Curated to  jq / yq (structured-data extractors whose non-zero exit is a fail-closed signal); opt out with  # allow-substitution-exit: <reason>.
check-pr-pathsA required check hung at “Expected⁠—Waiting” forever and the PR could never merge, because  paths: / paths-ignore: / branches: on  pull_request skipped the workflow without reporting (a stacked PR on a non-main base is the branch-filter trap).
check-pipefail-grep-pipeA teardown check reported a still-present secret as removed, because  secret_store ls | grep -q "$name" under  pipefail let grep’s early exit sigpipe the producer, surfacing 141 as no-match once the listing outgrew the pipe buffer.

Identity (Tier 1, default-on)

HookFailure it prevents
check-pinned-base-imagesThe base image you reviewed and the one CI built diverged, because  FROM node:22 is a mutable tag the registry can re-point. Demands a  @sha256: digest.
check-pinned-downloadsA tampered release or compromised mirror swapped the binary you  curled and then ran, because the download carried no checksum / signature check. Also fires on the one-line installer that never touches disk⁠— curl -fsSL … | sudo sh⁠—which pipes unverified bytes straight into a shell.

Opinionated (Tier 2, opt-in)

HookFailure it prevents
check-always-reporterA gated workflow stranded a required check at “Expected⁠—Waiting” when the decide gate skipped every work job. Assumes a decide-job + always() reporter pattern.
check-required-reporterA new  always() reporter shipped as a green-but-never-required check because nothing tied a workflow’s reporters to the branch-protection required-set. Assumes the required-set is mirrored from these annotations.
check-inline-run-lengthA long inline  run: block shipped unchecked (unquoted expansions, missing  pipefail) because shellcheck / shfmt / shellharden only see standalone  .sh files.
check-concurrencyNew pushes queued behind stale runs instead of cancelling, because a  concurrency: block omitted  cancel-in-progress and it silently defaulted to  false.
check-static-concurrencyA required check hung at “Expected⁠—Waiting” forever, because a static workflow-level  concurrency.group (no  github.ref / head_ref key) let a sibling ref’s run cancel this one’s pending run wholesale before any job⁠—and its  always() reporter⁠—ever started.
check-requires-concurrencyA  pull_request(_target) workflow shipped with no concurrency: block at all, so every push to a PR started a second full run instead of cancelling the superseded one⁠—stacking runs on a capped, shared runner pool. (check-concurrency only validates a block that exists; this one requires it to exist.) Satisfied by a block at the workflow level or on any job. Opt out with  # concurrency-not-required.
check-externalized-markersA workflow guard that scans inline  run: for a policy marker (e.g. a history-rewrite command that demands  fetch-depth: 0) went blind and passed vacuously the moment that command moved into  .github/scripts/*.sh or a composite action. Flags any job where the marker is reachable only through that indirection.
check-path-gate-depsA gated job silently skipped⁠—and its  always() reporter went green⁠—on the exact PR that changed a file the job depends on, because the decide job’s path filters omitted a composite action or  .github/scripts/ helper. Verifies every gated job’s static dependencies (composites, run scripts one  source hop deep, and  # gate-deps:-declared paths) are covered by the decide filters; suppress one dep with  # path-gate-ok: <dep> <reason>.
check-failure-notifier-coverageA new push / schedule workflow failed silently forever because  ci-failure-notify.yaml’s  on.workflow_run.workflows list (necessarily a hand-copied list⁠— workflow_run has no wildcard) was never updated. Round-trip freshness check: the list must equal the tree’s push / schedule workflow names; prints the corrected block on mismatch. Pass  --require-notifier to fail when the notifier workflow itself is missing.

Unrelated bonus checks (Extras)

HookFailure it prevents
check-symlinksA tracked symlink with an absolute target (/Users/you/...) broke on every machine but the author’s.
check-unnamed-regex-groupsA RegEx’s match handling went positional and brittle because a  re.* literal used an unnamed  ( ) group.
check-global-stdio-swapConcurrent calls clobbered each other’s output because code reassigned the process-global  sys.stdout to capture I / O.
check-claude-modelA  claude-code-action step billed Opus silently because it omitted  --model and rode the action’s expensive default tier.
check-drift-guardsA copies-agree (“drift guard”) test shipped with no stated reason why a single source of truth is infeasible⁠—the duplication it polices kept drifting anyway. Requires  @pytest.mark.drift_guard("<why no SSOT is feasible>") on any Python test whose name / docstring reads as a drift guard, so the judgement is reviewed, not implied. JS / TS / shell suites (*.test.mjs, bash tests) carry the same guards without that decorator, so a sibling phrase pass covers them⁠—annotate a flagged line with  drift-guard-ok: <why no SSOT is feasible>.
check-graceful-handwaveA doc or comment claimed the code “fails gracefully”⁠—a guarantee that specifies nothing (which input? which exit code?)⁠—and nobody could tell whether the behaviour was real or wished-for. Scans prose (Markdown / rst) line-by-line and code comment-only; opt out by stating the behaviour:  allow-graceful: <what actually happens>. Pass  --prose to scan a free-standing text file (e.g. a PR body) line-by-line.
check-historical-commentsA comment narrating the past (“renamed from X”, “now uses Y”) rotted into a lie the moment the code moved⁠—the reader can’t see the old code, so the note was unverifiable from day one. Bans only tokens with no present-tense reading; opt out (e.g. a reader of a legacy on-disk format) with  # allow-history: <reason>.
check-doc-line-refsA doc cited source by exact line number and pointed at whatever now happens to live there after the next refactor. Bans  <file>.<ext>:<N> and  (L<N>)-style cites in Markdown (fenced code blocks and any  CHANGELOG.md are skipped); cite a function / section / anchor instead, or suppress with ``.
check-flag-arityA CLI parser died with a raw  $2: unbound variable instead of a clean “⁠—branch needs a value,” because a  --branch) X="$2"; shift 2 arm trusted the loop’s outer  $# -gt 0 (which proves only  $1) and the flag was passed last. Flags any  case arm whose label is a  -x / --xxx / --xxx=* option that reads  $2 / shift 2 without its own guard; the guard must both precede the read and actually bail ([[ $#-ge-2]] || die, a self-guarding  ${2:?…}, or a  need_val / need_arg helper)⁠—a bare  [[ $#-ge-2-1]] whose result is discarded, or a guard placed after the read, does not count. Suppress with  # flag-arity-ok: <why>.
check-secret-file-permsA credential file (*token*, *.pem, *npmrc*, …) was created world-readable with the default umask and only  chmod 600’d on a later line, so a co-tenant could read it in the window between. Flags a secret-named create (> / >>, touch, tee, non-private  install) that is tightened by a  chmod 0?[46]00 on the same path within ~3 lines; a standing / inline  umask 077 or  install -m 600 is accepted, and a create with no nearby chmod is not flagged. Suppress with  # secret-perms-ok: <reason>.

Sanitize untrusted text before your agent sees it

Most prompt-injection tools run a classifier over the text and hope it generalizes. This library targets a narrower, verifiable claim: the specific byte-level channels⁠—invisible Unicode, ansi escapes, human-hidden Html, confusable glyphs, exfil-shaped urls⁠—that let an attacker smuggle a payload the operator can’t see but the model still reads. Every layer is a deterministic transform you can unit-test with equality assertions.

npm install agent-input-sanitizer

Quick start

import { sanitize } from "agent-input-sanitizer";
 
// Layer 1 (invisible chars + ANSI), zero heavy deps:
const { cleaned, found, warnings } = await sanitize(untrustedText);
 
// Opt into the HTML layers for web ingress (lazy-loads ~200 ms of deps):
const result = await sanitize(pageSource, { html: true });

sanitize never throws and never silently drops content⁠—any change comes with at least one  warnings entry.  found names the neutralized category codes (e.g.  ["cf-format", "hidden-html"]); cleaned is the safe text, with placeholders where hidden html was spliced out.

Pull requests on other projects

Mermaid diagrams now generate unique element IDs

My site uses Mermaid diagrams for compact, searchable, and dynamically styled graphics. However, the  a11y accessibility checker revealed a “bigly” problem. When a page contains multiple diagrams, some element IDs collide (like those for arrowhead markers and node containers). Thus, calling  url(#arrowhead) in the third diagram might bind to a marker defined in the first. Arrowheads vanish, click handlers fire on the wrong diagram, and css styles corrupt.

The issue had been reported repeatedly since 2020 (#1,318, #3,267, #3,433, #4,346) with no comprehensive fix in sight. In February 2026, I aimed Claude Code at this problem. My PR 7,410 prefixed every element ID with its diagram’s unique svg container ID, making collisions impossible. I also ensured that all future diagram types generate unique IDs across diagrams.

Jest  --collect-only flag

Jest lacked a way to enumerate test cases without running them, unlike  pytest --collect-only. PR 16,006 and PR 16,259 fixed that.

KaTeX TypeScript cleanup

KaTeX’s TypeScript codebase carried dozens of escape-hatch  as any casts left over from its earlier Flow-to-TypeScript migration. Each one silenced a type error instead of fixing it, so the compiler couldn’t catch bugs in those spots. PR 4,171 replaced these with proper checks and tighter type definitions, then enables a lint rule so new  any casts can’t sneak back in.

Scss linting rule

I contributed a rule to stylelint-scss. I ran into the following issue:

  1. I defined a css --property.
  2. I defined the  --property using the scss variable  $var.
  3. In this specific context, browsers will not interpolate  $var which means the final css contains the literal “$var.”

To fix the problem,  $var must be interpolated into  #{$var}. My  custom-property-no-missing-interpolation rule catches and automatically fixes this mistake.

Find out when I post more content: newsletter & rss

Thoughts? Email me at                                 alex@turntrout.com (pgp)

Similar posts