Quire

22 October 2026 · 7 min read

A Go site engine that turns a folder of markdown into a complete website served from a single binary, with plugins for anything live.

  • Golang
  • Templ
  • Datastar

Quire turns a folder of markdown into a complete website: home page, listings, articles, navigation, light and dark themes, a sitemap and share cards. The default look is good enough to ship, and every layer of it can be replaced without touching the others. This site runs on it, as do a few others.

Motivation

I used to build sites like this with Astro, but I never enjoyed the JavaScript/npm ecosystem. I also wanted dynamic sites - I'm building a Shopify-style art gallery - without doing it the usual client-side SPA way with React, Svelte and the like.

Approach

  • A single Go binary serves the whole site
  • Pages are Templ components, which are just Go code
  • Interactivity is Datastar, so there's no JavaScript build step and state lives on the server instead of being split between back end and front end
one Go binarypages + live patchesactionsmarkdownengineTempl pagespluginsbaked images + diagramsbrowser

The smallest site is a folder of markdown and this:

//go:embed all:content
var Files embed.FS

func main() {
	content, _ := fs.Sub(Files, "content")
	site := quire.Site{Name: "Example", BaseURL: "https://example.com"}
	quire.Run(content, site, func(e *quire.Engine) error {
		_, err := e.AddCollection(quire.Spec[quire.Meta]{
			ID: "posts", Markdown: "posts/*.md",
		})
		return err
	})
}

That's a working site: home page, post index, post pages, navigation, light and dark themes, sitemap and robots.txt, with no templates or CSS of its own.

Plugins

Anything the engine doesn't do itself - a basket, a search box, a comment thread - is a plugin.

  • A plugin is two methods: a name, and an attach that mounts its routes, assets and state
  • Its URLs live under a namespace taken from its name, so two plugins can't collide, and any public path it claims is checked at boot
  • Its CSS sits in its own cascade layer, so a site can restyle a plugin but never the reverse
  • A site mounts a plugin in one line, and moving a plugin into Quire itself is just moving its file
type Plugin interface {
	Name() string
	Attach(*Mount) error
}
e.Use(basket.New(catalogue))

Live pages

This is the part that replaces the SPA. State lives on the server, and changes are pushed to the browser over Datastar's server-sent events, grouped by topic. Add something to the basket in one tab and every open tab updates:

add to basketpublishpatchpatchpatchtab Aserverbasket topictab Btab C

The basket's nav link and its add button are plain markup. data-init opens the stream, data-text shows the count, and data-on:submit posts the form without leaving the page:

<a href="/basket"
	data-signals="{basket:{count:0}}"
	data-init="@get('/_basket/events')">
	Basket <span data-text="$basket.count"></span>
</a>

<form method="post" action="/_basket/add"
	data-on:submit="@post('/_basket/add', {contentType: 'form'})">
	<input type="hidden" name="sku" value={ item.SKU }/>
	<button type="submit">Add</button>
</form>

When the basket changes, the server publishes the new count to everyone watching that visitor's basket. A plugin's signals land under its own name, so this becomes $basket.count on the page and can't collide with another plugin's:

p.streams.Publish(id, func(s *quire.Stream) error {
	return s.Signals(map[string]int{"count": b.Count()})
})

That's the whole client side - there is no JavaScript to write.

  • A page is rendered and cached once for everyone; the per-visitor bits, like a basket count, are patched in over the stream afterwards
  • A session cookie tells visitors apart, so the server knows whose basket a patch belongs to. It's only ever set on a page navigation, never from a background request
  • The server always pushes. It never nudges the page to come back and fetch its own update

Bake

Images and diagrams are where most of the engineering went. Both are produced by one step, quire bake, and the binary that serves the site can't produce either.

off diskembeddedimagesbakemermaid in markdowncachebundle + journaldev serverproduction binary
  • The cache is incremental: over an unchanged library a bake costs a hash per file and opens no image
  • Each run links a fresh bundle out of the cache, with a journal listing exactly what's in it
  • Every file is written whole and renamed into place, so an interrupted bake just picks up where it left off
  • The serving binary has no codecs and no diagram renderer, so anything missing from the journal is a 404 - never a surprise CPU bill in production
  • Dev reads the same bundle off disk that production reads from the embed, so there's no dev-only path to get caught out by

Images

Image sizes aren't tied to the layout. There's one global ladder of widths, so I can restyle a grid without re-encoding the library. The bake orders formats per image by measuring the bytes, because which codec wins depends on the picture. AVIF is supported but off by default: on this site's art it came out bigger than WebP and took six times as long to bake.

In a template, an image is one line:

@img.Img(quire.SlotPage, "The ridge at first light")

and the bake's journal decides what that line becomes - here every width on the ladder up to the master, WebP first and JPEG as the fallback:

<picture>
  <source type="image/webp"
    sizes="(min-width: 46.5rem) 43.5rem, calc(100vw - 3rem)"
    srcset="/_quire/img/b73b….webp 232w,
            /_quire/img/41ba….webp 408w,
            /_quire/img/4a24….webp 680w,
            /_quire/img/3eac….webp 1096w,
            /_quire/img/ddbc….webp 1800w">
  <img src="/_quire/img/73d0….jpg" width="1800" height="1200"
    sizes="(min-width: 46.5rem) 43.5rem, calc(100vw - 3rem)"
    srcset="… the same five widths, as JPEG"
    loading="lazy" decoding="async" alt="The ridge at first light">
</picture>

Diagrams

The diagrams on this page are Mermaid in the markdown, drawn at bake time and inlined as SVG. Inlining means it picks up the site's palette and switches with light and dark mode. Labels are measured in the fonts the site serves, and if those fonts change without a rebake the site refuses to load, rather than ship labels that overflow their boxes. A site that takes content at runtime (from a CMS, say) can opt into the renderer and draw at load instead, with byte-identical output.

The Bake diagram above is just this in the markdown, with :::spot picking a palette colour for one node:

```mermaid
flowchart LR
  images[images] --> bake
  md[mermaid in markdown] --> bake
  bake:::spot --> cache[(cache)]
  cache --> bundle[bundle + journal]
  bundle -->|off disk| dev[dev server]
  bundle -->|embedded| prod[production binary]
```

Why a separate package

Encoding and drawing live in their own package, and the engine never imports it. So a serving binary can't produce an image or a diagram - not because it's told not to, but because it doesn't have the code - and it's about 9MB smaller for it. The only thing the two sides share is plate: the journal format, the width ladder and the hashing.

quire commandserving binaryyour sitequirequire CLIquire/bakeimage codecsquire/diagramMermaid rendererplate

I considered two alternatives:

  • Encode and draw on first request. Simplest, but dev and production drift apart, and a missing image costs CPU instead of failing the build
  • One package with a runtime flag. Same rule, but enforced by discipline rather than by the linker

Caching

Compression happens once per page, never per request. Whether a response can be cached is decided when its route is mounted, not by looking at the request:

POST, stream, filefixed pagearticlerequestroutestraight to its handlerrendered once, heldsize-capped cache
  • Fixed pages are rendered and compressed on first use and kept for the life of the process
  • Articles live in a size-capped cache keyed on path and encoding. Cookies never go in it, so one visitor's session can't leak to another
  • Small image files are held in memory, and large ones stream from disk or the embed