5.9 KiB
Type-Ahead Search for Roux
Date: 2026-05-19
Overview
Add a search box to the recipe index page that filters recipes by title or filename as the user types. The index page will be client-rendered from embedded JSON data, with progressive enhancement via a small vanilla JS script.
Motivation
As the recipe collection grows, finding a recipe by scanning an alphabetical list becomes tedious. A type-ahead search box provides instant filtering with no page reloads.
Scale Assumption
The recipe collection is assumed to be small — hundreds, at most ~1000 recipes. All data is embedded in the page at load time. No pagination or server-side search queries are needed.
Architecture
Data flow
- Server (Haskell /
Html.hs) renders the index page shell — navbar, search<input>element, and two JSON blobs in<script type="application/json">tags. - No server-rendered recipe list. The
ul.roux-recipesand all recipe content is rendered entirely by JS from the embedded JSON. - JS (inline
<script>, vanilla, no dependencies) reads the JSON on page load, renders the initial recipe list, and re-renders on search input or sort-mode change.
Progressive enhancement
Without JavaScript the page shows the navbar, search input, and a message like "Enable JavaScript to browse recipes." This is acceptable for a personal/family site where JS availability is assumed, but the shell still provides useful navigation.
If server-side rendering is desired later, the server can continue to render the list, and JS can use the embedded JSON for client-side filtering only.
Routes / Hash-based sort mode
- Sort mode is stored in the URL hash:
#alpha(default) — alphabetical list#tags— grouped by tag#course— grouped by course
- JS reads the hash on load and on
hashchangeevents. - Sort links in the navbar set the hash (e.g.,
<a href="#tags">By Tag</a>). - This makes sort modes linkable and bookmarkable without server round-trips.
The existing server routes GET /, GET /sorted/tags, and GET /sorted/course all render the same shell page (same HTML, same JSON, same JS). The JS reads the hash to initialise the sort mode. Visiting a server route without a hash defaults to #alpha. The server-side route distinction is no longer needed; the three endpoints can serve identical content.
JSON Payload
Recipe data
Embedded in <script type="application/json" id="roux-recipe-data">:
[
{
"title": "Carrot Risotto",
"filename": "carrot-risotto.cook",
"course": "Main Course",
"tags": ["italian", "vegetarian", "risotto"]
},
...
]
Fields:
title— display title from recipe metadata (or filename fallback)filename— recipe file path, used for link URLcourse— optional course/category from metadatatags— list of tags from metadata (empty array if none)
Error data
Embedded in <script type="application/json" id="roux-errors-data">:
[
{"title": "Broken Recipe", "filename": "broken.cook"}
]
Rendered at the bottom under an "Unparseable recipes" heading, matching current server-rendered behavior.
Client-Side Rendering (JS)
Initialization
- Parse JSON from script tags.
- Read
location.hashto determine sort mode (default:#alpha). - Render the recipe list.
- Attach event listeners:
inputon search box,hashchangeon window.
Search behavior
- Search matches against
titleandfilename, case-insensitive (simpleincludesaftertoLowerCase()). - Search input is cleared on page load.
- When search text is non-empty:
- Recipes are filtered to matches.
- List is always rendered as flat alphabetical (grouping suppressed).
- When search text is empty:
- Recipes render according to the current sort mode.
Sort modes
All three modes match the current server-rendered output:
AlphaSort (#alpha):
- Flat
<ul class="roux-recipes">with<li><a href="/recipes/...">title</a></li>. - Sorted alphabetically by title.
TagSort (#tags):
- Grouped by tag:
<h3>TagName</h3>followed by<ul class="roux-recipes">for each tag group. - Tag order is alphabetical.
- Recipes that appear in multiple tags appear under each tag.
- Untagged recipes are omitted (matches current behavior).
CourseSort (#course):
- Grouped by course:
<h3>CourseName</h3>followed by<ul class="roux-recipes">for each course group. - Recipes without a course are omitted.
Styling
Reuses existing CSS classes: .roux-recipes, .roux-course, .tag, .roux-navbar, etc. No new styles needed for the recipe list itself.
Files Changed
src/Roux/Html.hs
- Add
Aesonimport and encode recipe list + error list as JSON. - Modify
indexPageto:- Embed JSON blobs.
- Render search input (HTML element, present in all views).
- Render the JS script block.
- Remove server-rendered recipe list rendering (move sort-mode logic to JS).
- Modify
SortModeto become part of the JSON payload rather than server-side rendering selection.
src/Roux/RecipeIndex.hs
- Minor: ensure
riTitleandriFilenameare exported and accessible for JSON encoding. No structural changes expected.
package.yaml
- No new dependencies needed (
aesonis already listed).
Testing
- Manual testing in browser: verify all three sort modes render correctly.
- Verify search filters by title and filename (case-insensitive).
- Verify clearing search restores sort mode.
- Verify hash-based navigation works (back/forward buttons).
- Verify error recipes still shown at bottom.
- Run
./hs stack buildand./hs stack testbefore marking complete.
Future Considerations (explicitly out of scope)
- Server-side search endpoint — not needed at this scale.
- Debouncing/throttling — not needed with client-side in-memory filtering on small dataset.
- Fuzzy search — not needed with simple substring matching on small dataset.
- External JS file — inline keeps things simple for this size.