All notes

A small shell in the browser

The terminal on the home page is a small shell over the content of this site, tested without a browser.

The home page starts with a terminal. It is a small shell over the content of this site, so you can list the notes, read a summary with cat or search every note with grep. Most of it is plain TypeScript that knows nothing about React, and the page still works when JavaScript is off.

Files from the content

A shell needs files, and this site already has them as notes, projects and a few YAML files. So at build time, buildTerminalFs() turns the content into a small virtual file system. Every note becomes a file under notes/, with its summary and a link to the full page:

for (const n of notes) {
  notesDir.children.push(`${n.slug}.md`);
  nodes[`notes/${n.slug}.md`] = noteFile(n);
}

The home page passes this file system to the terminal as props, and it holds summaries only. The full text of every note goes into a separate file, the search index, which the build writes next to the other assets. Only grep needs it, so the terminal loads it later.

Logic without the browser

A terminal has a lot of small rules, so the logic lives in src/lib/terminal/ as pure functions with their own unit tests. execute() takes the file system, the current directory and one input line, and it returns the result as data:

export interface ExecResult {
  cwd: string;
  out: Output[];
  clear: boolean;
  effects?: Effect[];
  needsIndex?: boolean;
}

cwd is the directory after the command, and out holds the lines to print. The last two fields are for commands that need something from outside the terminal, and the next sections explain them.

A reducer keeps the session around execute(): the history with !! and !n, and the greps that wait for the index. The React component only renders that state and turns key presses into actions. Tab completes command names, paths and the arguments of theme, analytics and man. When there is nothing to complete, Tab moves the focus as usual, so keyboard users don't get stuck.

Theme and analytics

Two commands change more than the terminal. theme dark switches the colours of the whole site. analytics on or analytics off answers the same question as the banner that asks whether anonymous page views may be counted. But execute() is pure, so it can't touch the page. It returns an effect instead, a small object that says what should happen:

export type Effect = { type: "theme"; choice: ThemeChoice } | { type: "analytics"; choice: "on" | "off" };

The reducer adds these effects to a queue in the state. After the next render, the component applies them through the same window APIs that the header toggle and the consent banner use:

if (e.type === "theme") api(w)?.set?.(e.choice);
else consentApi(w)?.set?.(e.choice);

If one of these APIs is missing, the command prints an error line instead of breaking the terminal.

Search on demand

grep works the same way. When the index is not there yet, grep prints "searching…" and sets needsIndex. The reducer keeps the line as pending and sets the search status to loading. The component watches that status and fetches the index once:

if (state.search.status !== "loading") return;
loadSearchIndex(fetch, searchIndex).then(
  (index) => live && dispatch({ type: "search", index }),
  () => live && dispatch({ type: "search", index: null }),
);

When the index arrives, the reducer runs every pending grep again, this time with the index, and replaces "searching…" with the matches. If the fetch fails, the line says so instead.

Without JavaScript

Without JavaScript, a terminal would be a text field that does nothing. So the server renders the finished intro: the name, a short text and the output of ls, which is a list of real links to the sections of the site. The input row is already in the HTML, but CSS hides it until the island is hydrated (the browser attaches event handlers to HTML that is already there):

[data-island="terminal"]:not([data-hydrated]) .terminal__inputrow,
.terminal__inputrow--pending {
  visibility: hidden; /* keeps its space: the terminal does not grow on hydration */
}

The same trick fixed a slow page. After hydration, the intro types itself again when the browser is idle, but not if you prefer reduced motion or already started typing. At first the replay created the rows again, after the web fonts had loaded. The browser then counted them as a new, larger largest contentful paint (when the biggest element appears), and on a throttled phone that value went from 1.6 to 6.2 seconds. Now all rows stay mounted, and the ones the replay hasn't reached yet are hidden with visibility: hidden, so the replay only changes visibility and text. The largest paint stays at 1.6 seconds, the value from How this site works, where the other decorative work also waits until the browser is idle.

Back to top