← Back to the den logs

Filtering a Static Archive Without Building an App

A small Astro archive gained accessible filters, shareable URLs, and browser history without adding a backend or framework island.

The archive for this site had four posts and one growing problem: the word “blog” was doing too much work. FoxOps entries, model notes, and personal Den Logs all lived in the same list. That was manageable at four. It would get tiresome later, especially for someone who arrived looking for systems work and not my opinions about game jams.

I wanted filters, but I did not want the archive to become an application. The site already builds to static files and Nginx serves them without a database or server-side runtime. Adding either one for three buttons would have been a poor trade.

The solution was a small piece of progressive enhancement. Every post remains in the generated HTML. Astro calculates the categories and counts during the build, then an inline script handles visibility, URL state, and browser history after the page loads.

One content rule does two jobs

Each post already has an ordered tag list in its frontmatter. I made the first tag the primary type:

tags: ["FoxOps", "Astro", "Accessibility"]

That order is now a contract rather than decoration. The archive reads tags[0], collects the types that actually exist, and counts matching posts during the static build. A secondary tag can describe the subject without changing where the post belongs.

There is a maintenance cost here. Anyone publishing a FoxOps entry must put FoxOps first. I prefer that small authoring rule to another schema field because the same value already needed to appear in the visible tag list. The publication workflow checks it before a post reaches production.

The category controls are generated from the collection, not copied into the page by hand. If one category has two posts, its button says two. If a category disappears, the empty control is not rendered. The archive does keep a preferred order for the three known types, followed by anything new it discovers. That keeps the navigation stable without making the type list closed forever.

The static page stays complete

The filter script never requests data. Each article is rendered with two data attributes:

<article data-post-type="foxops" data-post-type-label="FoxOps">

Choosing a filter sets the native hidden property on entries that do not match. Choosing “All posts” removes that restriction. The page already contains the titles, dates, descriptions, links, and tags before JavaScript runs.

That detail matters more than the tiny size of the script. If JavaScript fails or is disabled, the fallback is a complete archive. A crawler, an old browser, or a reader with an extension that blocks scripts still gets every post. The enhancement can fail without taking the content with it.

I also avoided an Astro island or a client framework. Neither would have been inherently wrong, but both would have introduced machinery that this interaction does not need. A click handler, a loop over existing elements, and hidden are enough.

A filter should have a real URL

Filters that exist only in the DOM are easy to build and annoying to share. The archive writes the selected type into a normalized query parameter:

/blog/?type=foxops

A valid parameter is applied on the first load. An unknown value falls back to all posts instead of producing an empty archive. Clicking a category uses history.pushState, so the address bar changes without reloading the page. A popstate listener restores the prior filter when the reader uses Back or Forward.

This was one of the places where a few extra lines made the feature feel finished. Without history handling, the URL would change but the page would not follow the browser’s navigation state. That mismatch is subtle until someone presses Back and nothing appears to happen.

The URL is also deliberately plain. It does not expose an internal numeric ID or depend on generated JavaScript state. The value is the lowercase, hyphenated category name a person can read and edit.

Accessibility is state, not styling

The selected button has an orange background, but color is not the state contract. The controls sit in a group named “Filter den logs by type,” and every button carries aria-pressed. The script updates that attribute whenever the selection changes.

A polite live region reports the result, such as “Showing 2 FoxOps posts.” Each button also displays its count before it is selected. The CSS gives the controls a minimum height of 44 pixels and lets the row wrap on narrow screens.

Those choices keep the control understandable to a screen reader and usable on a phone. They also made the browser tests more precise. Instead of asserting that an orange class appeared, the tests locate the named group, check the per-type counts, press the FoxOps button, confirm the query string, and count the visible archive entries.

The test caught more than the feature

The archive behavior runs in both desktop and mobile Playwright projects. The test derives expected counts from the rendered posts rather than freezing today’s numbers into an assertion. That matters on a blog, where a correct count is supposed to change whenever a post is added.

The same development pass exposed an unrelated build hygiene problem. Astro was scanning generated Playwright report directories and emitting hints about files that were never source code. Excluding artifacts, playwright-report, and test-results from the TypeScript project restored a clean check. Generated evidence belongs outside the compiler’s input set, even when it lives near the project.

The finished archive still deploys as ordinary HTML, CSS, and a short inline script. Nginx has no new service to reach, no API to proxy, and no database to keep alive. The public surface stayed static; only the reader’s view became interactive.

The remaining operational contract is simple: the first tag must be correct. When it is, adding a post updates the controls and counts on the next build without touching the filter code. That is a maintenance trade I am happy to keep.