Packaging the website
The website is a small web application: a page the browser loads, a client that boots it, and a server that renders it first for a reader and a crawler. Vite bundles the client; the server runs under Bun. This chapter is the files around the code — the page, the manifest, the TypeScript settings, the build, and the small program that keeps the build and the server running while the site is developed.
The page
The page is the shell the application fills. It carries an empty #root for the view to mount into, and the module that boots the client.
The site is set in two families. Space Grotesk carries the headings, and Lilex carries everything else — the running text as well as the code, since the whole site is set in mono. Lilex has no Google entry, so it arrives as three @font-face rules pointing at its own files, with IBM Plex Mono standing behind it in the stack for the moment before they land. Noto Color Emoji comes along for the emoji the documentation shows.
The server rewrites three spots: the #root it fills with the rendered body, the head where it inlines the model, and the <title> it replaces with the route's own. The first two it finds by exact text, so the strings <div id="root"></div> and </head> matter as much as the markup; the title it finds by its element, so the words below are a default no reader sees.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>loom — programs written to be read</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Color+Emoji&display=swap"
rel="stylesheet"
/>
<style>
@font-face {
font-family: 'Lilex';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/lilex/files/lilex-latin-400-normal.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Lilex';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/lilex/files/lilex-latin-500-normal.woff2') format('woff2');
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'Lilex';
src: url('https://cdn.jsdelivr.net/npm/@fontsource/lilex/files/lilex-latin-600-normal.woff2') format('woff2');
font-weight: 600;
font-display: swap;
}
</style>
<script type="module" src="/src/client.ts"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>The manifest
The manifest names the package, its commands, and its dependencies. The site holds its own components, so it leans on only a few packages: foldkit, which renders a page on the server and hydrates it in the browser, effect and @effect/platform-browser beneath it, marked to turn the documentation's woven prose into HTML, and shiki to colour the book's code while the site is built.
Foldkit's server rendering still ships as experimental, and a release may change it. So the manifest pins Foldkit to one exact version rather than a range, and the site moves to a new one on purpose rather than by accident. The type comes from the page's own head rather than from a package.
Before the site runs or builds, one step prepares the content it reads. It weaves the documentation, which is its own corpus of prose sitting beside this one, into the JSON the docs page renders. That JSON derives from a loom, so nothing the documentation shows is a copy of anything — change a chapter and the page changes with it. The landing's example needs no such step: the document it types is data in the section itself, since it is a teaching document rather than a corpus of Loom's own.
The same step weaves Loom's own book, corpus/book.loom, for the source page. The documentation is woven by the published loom command and committed, as a small file that changes when the docs do. The book is two megabytes and changes with almost every commit, so it is never committed. It is woven wherever the site is built instead, by the workspace's own command run from source, which needs no network and is always the Loom of the commit being built.
Once the book is woven, the step colours its code. The colour:source script runs the program in src/colour.ts, which writes the book in colour to source.coloured.json, the file the server reads. The coloured book is never committed either, for the same reason as the woven one.
Because the book is never committed, a fresh checkout has none until the step runs. The step runs before every dev, not only when the woven book is missing. Weaving the book and colouring it take about six seconds, and in return every session starts from the corpus as it stands rather than from a book an earlier session left behind.
Each weave is pointed at a file rather than at a directory, because weaving takes the corpus around the path it is given and a directory resolves to the one above it.
Development runs the server production runs — the same file, over the same dist — so a developer reads the page a reader is sent rather than an approximation of it. What dev adds is only the two watchers that keep dist and the server current: Vite rebuilding the client on a change, and Bun restarting the server when a module it renders from changes. Neither stands in a request's way. A small program of their own, src/dev.ts, starts the pair and stops them as one.
The rest follow from that. The build command writes the bundle to dist, then runs src/render-ahead.ts to render every page into it. serve runs the server over what the build wrote — the pair the image runs — and preview is the two in order, for looking at the built site before shipping it. Vite's own preview server is not among them, and deliberately so: it hands out dist as files with nothing in front, so it renders no page, and what it shows is not what a reader would be sent.
{{ effect = ${effect} }}
{
"name": "loom-website",
"type": "module",
"private": true,
"version": "0.0.0",
"scripts": {
"weave:docs": "bunx @athrio/loom weave ../docs/index.loom src/docs.woven.json",
"weave:source": "bun ../packages/loom/src/main.ts weave ../corpus/book.loom src/source.woven.json",
"colour:source": "bun src/colour.ts",
"content": "bun run weave:docs && bun run weave:source && bun run colour:source",
"predev": "bun run content",
"dev": "bun src/dev.ts",
"prebuild": "bun run content",
"build": "bunx --bun vite build && bun src/render-ahead.ts",
"serve": "bun src/server.ts",
"preview": "bun run build && bun run serve"
},
"dependencies": {
"@effect/platform-browser": "::[effect]",
"effect": "::[effect]",
"foldkit": "0.168.0",
"marked": "^18.0.5",
"shiki": "^4.3.1"
},
"devDependencies": {
"vite": "^8.1.3"
}
}The browser TypeScript config
The client runs in the browser and the server on Bun, and their type environments cannot share one program. Foldkit's runtime reads Vite's import.meta.hot, and Bun's platform types declare a different import.meta.hot; in one program the two collide, and the client's reads stop type-checking. So the configuration splits. This one checks the browser half — the client, the view, and everything they reach — reading Vite's types through the ambient declaration beside it, and leaves the rest to the server config: the server, the pages module it shares with the build, the search index, the two build programs that colour the book and render the pages ahead, and the development program.
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"module": "preserve",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"target": "es2022",
"lib": ["es2023", "dom", "dom.iterable"],
"types": ["bun"],
"skipLibCheck": true
},
"include": ["src"],
"exclude": [
"src/server.ts",
"src/pages.ts",
"src/search.ts",
"src/colour.ts",
"src/render-ahead.ts",
"src/dev.ts"
]
}The server TypeScript config
This checks the server half under Bun. Because the server never imports the client runtime, Vite's import.meta.hot never enters its program, and the collision the browser config avoids by exclusion this one avoids by construction. The development program runs under Bun as well, and it reaches Vite only as a command it starts, so it is checked here beside the server.
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"module": "preserve",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"target": "es2022",
"lib": ["es2023", "dom", "dom.iterable"],
"types": ["bun"],
"skipLibCheck": true
},
"include": [
"src/server.ts",
"src/pages.ts",
"src/search.ts",
"src/colour.ts",
"src/render-ahead.ts",
"src/dev.ts",
"src/raw.d.ts",
"vite.config.ts"
]
}Text imported as text
The view reads a few files as plain text: the SVG drawings the commands are illustrated with, and the licence. Vite and Bun both read a path ending in ?raw as the file's text, but the server's type check loads neither Vite's types nor any other declaration for such a path. This one line tells it that a ?raw import is a string. The browser config already learns the same from Vite's own types.
declare module '*?raw' {
const text: string
export default text
}The Vite types
The client reaches for Vite's ambient types — import.meta.hot above all, which Foldkit's runtime reads — so this one line brings them in.
/// <reference types="vite/client" />The build
The build is Vite with the page as its input, targeting a runtime recent enough that the Effect and Foldkit output needs no down-levelling. It writes the page and its assets to dist, where the server reads them.
Vite never serves this site. It compiles the client and stops there, so nothing in this file configures a server, a port, or a socket — the site has a server of its own, and it reads what Vite wrote. Watching is the same build left running, which is why development needs no second arrangement.
That is also why no plugin appears here. A plugin can only reach an application that runs the bundler it was written for, and the site is the one application that no longer runs Vite at request time. Anything wanting to reach a page in development reaches it from outside, through the browser, rather than from inside the build.
Left watching, the build waits a moment before it starts. A tangle rewrites the site's sources one chapter at a time, a few dozen milliseconds apart over about half a second. A build that started on the first write would read some files new and others old, or one half written, and fail on an export that has not landed yet. So the watcher builds only once 300 milliseconds pass without a change, and a tangle yields one build of the finished sources. The delay is a setting of the watcher alone, so a one-off build, like the image's, still starts at once.
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
esbuild: { target: 'es2022' },
build: {
target: 'es2022',
outDir: 'dist',
rollupOptions: {
input: { index: fileURLToPath(new URL('index.html', import.meta.url)) },
watch: { buildDelay: 300 },
},
},
})Two watchers, started and stopped as one
Development runs two watchers side by side. Vite's build rebuilds the client into dist whenever a source changes, and Bun restarts the server whenever a module it renders from changes. The development program starts the two together and stops them together. Like the server, it is an Effect program started from the Bun runtime edge.
A shell line could start both, with an ampersand between them, but it cannot reliably stop them. A signal sent to the shell alone stops only the shell. Whatever the shell started in the background keeps running with no parent to stop it. A server left behind holds the port the next one needs, and a build left behind goes on rebuilding into the same dist. Two builds that write one folder at once empty and refill it over each other, and one of them fails when a file it was copying is deleted under it.
Each watcher is a child process the program runs until the child exits. The watcher function starts one in the site's own folder, hands it the terminal, and yields its exit code once it stops. The child stays in the program's process group, which is the set of processes a terminal signals together. So an interrupt typed at the terminal reaches both watchers directly, and so does the terminal closing. The scope the child runs in also owns it. When the program alone is told to stop, the scope closes and stops the child on the way out.
import { Effect, FileSystem } from 'effect'
import { ChildProcess } from 'effect/process'
import { BunRuntime, BunServices } from '@effect/platform-bun'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const siteDir = join(dirname(fileURLToPath(import.meta.url)), '..')
const watcher = (command: string, args: ReadonlyArray<string>) =>
Effect.scoped(
Effect.flatMap(
ChildProcess.make(command, args, {
cwd: siteDir,
detached: false,
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
}),
(running) => running.exitCode,
),
)The client build is Vite's build left watching, with one difference from the build the image runs. It never empties dist. A build that empties the folder first leaves it without a page for as long as the new one takes to render, about a fifth of a second on every rebuild. A reload in that moment gets the unbuilt page, and a page loaded just before it asks for a stylesheet and a bundle that are already gone. A build that writes over the folder keeps every file in place until its replacement lands. It also leaves the old bundle beside the new one, for a page that still names it.
The server is the very file the image runs, under Bun's watcher.
const clientBuild = watcher('bunx', ['--bun', 'vite', 'build', '--watch', '--emptyOutDir', 'false'])
const server = watcher('bun', ['--watch', 'src/server.ts'])Old bundles would pile up across sessions that way, so the program empties dist once, before either watcher starts. Then it races the two. Neither watcher is meant to finish, so whichever stops first has stopped development, be it a server that cannot take its port or a build that crashed. The race interrupts the other watcher, and the program exits with the code the first one stopped with. An interrupt or a termination signal that reaches the program alone stops the race at the runtime edge, and both scopes close, taking their watchers with them.
const development = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.remove(join(siteDir, 'dist'), { recursive: true, force: true })
const code = yield* Effect.raceFirst(clientBuild, server)
yield* Effect.sync(() => void (process.exitCode = code))
})
BunRuntime.runMain(development.pipe(Effect.provide(BunServices.layer)))