Skip to content
typovrak

Serving a blog post to curl: ANSI text from a static Astro site

7 min read— views

curl https://typovrak.tv/posts/github-workflow-in-the-terminal prints the post as coloured text, 80 columns wide, with its headings, tables, callouts and code blocks intact. A browser asking for the same URL gets the HTML. The switch is one rewrite rule in the Vercel config, keyed on the user-agent, in front of two prerendered text files per post. No function runs and no JavaScript is involved.

I shipped it on 2 September 2026 in one commit of 1,201 lines, about half of them tests. The switch is two of those lines. Everything else is the renderer, which is the part that decides whether the output is worth reading.

Versions below are Astro 7.0.3 with @astrojs/vercel 11.0.3, checked against the live site on 29 September 2026.

The same two URLs in a terminal, then the html coming back for a browser user-agent.

Table of contents

Open Table of contents

What does curl get from this site?

The post as text with colour, followed by three lines telling you where else it lives:

$ curl https://typovrak.tv/posts/github-workflow-in-the-terminal
From issue to merge in the terminal: the git and gh workflow
1 Sep, 2026 · 5 min read · CLI, git, GitHub, Open source

The issue-to-merge loop is nine commands in git and gh. The catch: the branch
linked to an issue does not close it, only a Closes keyword in the PR body does.

────────────────────────────────────────────────────────────────────────────────

Filing an issue, branching, opening the pull request and merging it all run in
git and gh, with no browser tab open. The whole loop is nine commands.
...
────────────────────────────────────────────────────────────────────────────────
Online: https://typovrak.tv/posts/github-workflow-in-the-terminal
Plain text: https://typovrak.tv/posts/github-workflow-in-the-terminal.txt
Read from the top: curl -s https://typovrak.tv/posts/github-workflow-in-the-terminal | less -R
The end of a curled post in a terminal, a bash block above the footer and its Read from the top line
The end of a curled post in a terminal, a bash block above the footer and its Read from the top line

That footer is there because a terminal drops you at the end of the output, so the last line is the first one you read. It hands you the command that pages the same thing from the top.

The colour in it is ANSI escape codes. The terminal reads ESC[1;32m as an instruction, bold green until further notice, and eats the ESC byte instead of drawing it. Anything that is not a terminal sees only the bytes, which is why less needs -R to pass them through: without the flag you read a literal ESC[1;32m where the title should be.

The root does the same for the post list:

$ curl https://typovrak.tv
typovrak
Notes on Arch Linux, NixOS, CLI tooling and web development, written up from
problems I had to solve myself.

Posts

1 Sep, 2026   From issue to merge in the terminal: the git and gh workflow
              5 min read https://typovrak.tv/posts/github-workflow-in-the-terminal
...
11 KB of coloured text against 110 KB of HTML

What curl gets is 11 KB, ANSI colour codes included. The same URL in a browser is 110 KB of HTML, 9.8 times more, and none of that 110 is needed to read the post. Without the colour the plain .txt drops to 8.5 KB, a thirteenth of the page.

How does the server tell curl from a browser?

By the User-Agent header, matched against a closed list. The whole mechanism is one route in Vercel’s Build Output config, added after the build by scripts/terminal-routes.mjs:

const terminalClient = [
  { type: "header", key: "user-agent", value: "(curl|[Ww]get|HTTPie|xh)/.*" },
];

const routes = [
  { src: "^/$", has: terminalClient, dest: "/index.ansi.txt" },
  { src: "^/posts/([^./]+)$", has: terminalClient, dest: "/posts/$1.ansi.txt" },
];

has is a condition on the request, so the route only fires when the header matches. dest is a rewrite, so the URL in the terminal stays /posts/<slug> while the file served is /posts/<slug>.ansi.txt. The dot in [^./]+ is what keeps /posts/<slug>.txt out of the rewrite, so the plain files stay reachable by their own URLs.

Four clients are on the list, curl, wget, HTTPie and xh, because their default user-agent is a bare name/version. A regex in someone else’s matcher is a regex whose semantics you have not read, so I checked it against production:

User-agent sentServedWhat it shows
curl/8.12.1text/plainThe normal case
curl/1.0 footext/plainThe trailing .* covers anything after the version
foo curl/1.0text/htmlThe match is anchored at the start of the header
CURL/8.0, WGET/1.0text/plainVercel compares case-insensitively, so [Ww] is redundant
curl alone, PowerShell/7.4, emptytext/htmlNo slash, or not on the list, so the HTML
Why the user-agent and not the Accept header

curl sends Accept: */* and so does wget, so content negotiation has nothing to negotiate on. The user-agent is the only header a command-line client sends that says what it is, and a script that wants the HTML anyway overrides it in one flag, curl -A Mozilla/5.0.

The same typovrak.tv URL in a browser and in a terminal, html on the left and text on the right
The same typovrak.tv URL in a browser and in a terminal, html on the left and text on the right

Why two text files per post?

Because escape codes are the right output for a terminal and the wrong output for a file: curl -o post.md on the coloured version writes ESC[1;32m in front of every heading. So each post is built twice, by two static endpoints whose .txt filename is also what makes Vercel serve them as text/plain:

URLPaletteFor
/posts/<slug>.ansi.txtansiWhat the rewrite serves to a terminal
/posts/<slug>.txtplaincurl -o post.md, a pager without -R, or an LLM
/index.ansi.txt, /index.txtbothThe post list, same split

All four are live for the post above: the coloured one, the plain one, and the list as index.ansi.txt and index.txt. Open the coloured one in a browser and the codes come back as text, a boxed ESC and a [1;32m in front of every heading, which is the whole argument for keeping the plain one.

The ansi text file open in a browser, each heading preceded by a boxed ESC and a literal [1;32m
The ansi text file open in a browser, each heading preceded by a boxed ESC and a literal [1;32m

My first plan, written in a TODO before any code, was one file and a ?raw query string to switch the colour off. That cannot work on a static host, because a query string never reaches a file on disk. Two files cost nothing at build time and each one gets a stable URL, so that is what shipped.

How does the markdown become terminal text?

By walking the same tree Astro walks. Its render() hands back HTML and nothing else, and HTML has already dropped the structure a terminal needs, while parsing the markdown a second time with my own parser would work right up to the day a table or a callout parsed differently from the site. So postTree.ts builds the processor Astro builds, and slips in a remark plugin whose only job is to hold on to the root:

let tree: Node | undefined;
const capture = () => (root: Node) => {
  tree = stripMdx(root);
};
const processor = await createMarkdownProcessor({
  gfm: true,
  smartypants: false,
  syntaxHighlight: false,
  remarkPlugins: options.mdx ? [remarkMdx, capture] : [capture],
});
await processor.render(body);

smartypants: false is not cosmetic. With it on, a --rebase in prose becomes an en dash, and a reader pasting that command out of their terminal gets a broken command. remarkMdx is in the chain for .mdx files only: the MDX grammar reads {braces} in prose as a JavaScript expression, and one of my .md posts carries ${pkgs.prisma-engines_6} in a Nix snippet.

From there terminal.ts walks the tree with no astro:* import, which is what keeps it under vitest. The parts that took more than one attempt:

Two test files, 541 lines for 651 lines of renderer, and I broke each rule in turn to watch its test go red. The rewrite is the one thing they cannot reach, since only Vercel reads that config, so it gets checked on a preview deploy with a browser and curl on the same URL.

Why only sixteen colours?

Because the reader’s own terminal theme should pick the shade. The palette is nine SGR codes and nothing from the 256-colour or truecolour ranges:

ElementCodeMeaning
Title, h1, h21;32bold, green
h3 and deeper, strong1bold
Emphasis3italic
Inline code36cyan
Link4;32underline, green
Metadata, URLs, rules2dim
Callout label32green
Warning label33yellow
The same index output under Catppuccin Mocha and Latte, the bold green title taking a different shade from each
The same index output under Catppuccin Mocha and Latte, the bold green title taking a different shade from each

Code 32 leaves the shade to the theme, which is Catppuccin Mocha’s on my machine and yours on yours. A truecolour value would reproduce the site’s exact accent and then sit unreadable on somebody’s light background.

The plain palette maps those same slots to markdown instead, # for headings and fences for code, and that paid off a day later: /llms.txt and /llms-full.txt are the same renderer called with that palette, because its output was already valid markdown.

Who else does this?

wttr.in and cheat.sh, both by Igor Chubin, are where I got the idea. Checked on 29 September 2026, each answers text/plain to a curl/8.12.1 user-agent and text/html to a Firefox one on the same URL, and wttr.in goes further by painting the weather in 256 colours. rate.sx, from the same author, switches too, but it labels the text text/html in both cases, so its Content-Type tells you nothing and the body is the only place the switch shows.

curl wttr.in in a terminal, ascii clouds and three days of forecast in colour-coded box-drawing tables
curl wttr.in in a terminal, ascii clouds and three days of forecast in colour-coded box-drawing tables

Those are services with a server behind them. A static site gets there with a rewrite rule and two extra files per page.

Try it

curl https://typovrak.tv
curl https://typovrak.tv/posts/github-workflow-in-the-terminal | less -R
curl -o post.md https://typovrak.tv/posts/github-workflow-in-the-terminal.txt
curl -A Mozilla/5.0 https://typovrak.tv/posts/github-workflow-in-the-terminal | head -3

The first two read in the terminal. The third saves the markdown without a single escape code. The fourth is how a script gets the HTML back, since the switch is keyed on nothing but the user-agent.

Every one of them needs the https://

Drop it and curl asks for port 80, Vercel answers a 308 to the https URL, and Redirecting... is the whole of what you read, unless you add -L. A trailing slash on the path 308s the same way, since the site emits none.


Share this post:

Next Post
Running freeCodeCamp on NixOS: the shell.nix Prisma needs

Test yourself

  1. 1. How does the site decide to send text instead of HTML?

    Select one answer

  2. 2. Why are there two text files per post?

    Select one answer

  3. 3. Where does the renderer get the markdown tree from?

    Select one answer

  4. 4. Why does the ANSI palette use only the 16 standard colours?

    Select one answer

  5. 5. Which of these does the terminal text pipeline also produce?

    Select at least 2 answers

Comments

Loads a GitHub-hosted comment widget. It is fetched only when you click, which exposes your IP to GitHub. See the legal notices.