14 KiB
Type-Ahead Search Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a client-rendered type-ahead search box to the recipe index page with hash-based sort mode navigation.
Architecture: Embed recipe data as JSON in the index page; render the recipe list entirely in vanilla JS. No new API endpoints or server-side search logic needed. Sort modes (alpha/tags/course) controlled via URL hash.
Tech Stack: Haskell (WAI, blaze-html, aeson), Vanilla JS, Pico CSS
Task 1: Add JSON encoding for recipe search data in RecipeIndex.hs
Files:
-
Modify:
src/Roux/RecipeIndex.hs -
Step 1: Add
aesonimport andRecipeSearchEntrydata type
Add a new data type for the JSON payload — a lightweight projection of RecipeInfo containing only the fields the JS needs.
-- | Lightweight recipe data for the client-side search JSON payload.
data RecipeSearchEntry = RecipeSearchEntry
{ rseTitle :: !Text
, rseFilename :: !Text
, rseCourse :: !(Maybe Text)
, rseTags :: ![Text]
}
deriving stock (Eq, Show, Generic)
instance ToJSON RecipeSearchEntry where
toJSON = genericToJSON defaultOptions{fieldLabelModifier = camelTo2 '_' . drop 3}
Also add to the module's export list: RecipeSearchEntry(..), toSearchEntry.
- Step 2: Add
toSearchEntryconversion function
-- | Convert RecipeInfo to the lightweight search entry.
toSearchEntry :: RecipeInfo -> RecipeSearchEntry
toSearchEntry info =
let (course, tags) = case riRecipe info of
Right r ->
( metaCourse (recipeMetadata r)
, metaTags (recipeMetadata r)
)
Left _ -> (Nothing, [])
in RecipeSearchEntry
{ rseTitle = riTitle info
, rseFilename = T.pack (riFilename info)
, rseCourse = course
, rseTags = tags
}
- Step 3: Ensure imports are correct and build
The module already imports Data.CookLang (which has Recipe, recipeMetadata, metaCourse, metaTags). Add GHC.Generics and Data.Aeson to imports:
import Data.Aeson (ToJSON, genericToJSON, defaultOptions, fieldLabelModifier, camelTo2)
import GHC.Generics (Generic)
- Step 4: Build to verify compilation
Run: ./hs stack build
Expected: Compiles successfully.
- Step 5: Commit
git add src/Roux/RecipeIndex.hs
git commit -m "feat: add RecipeSearchEntry JSON encoding for search payload"
Task 2: Rework Html.hs — shell page with embedded JSON and JS
Files:
-
Modify:
src/Roux/Html.hs -
Step 1: Add new imports and helpers
Add to imports at the top:
import Data.Aeson (encode)
import qualified Data.ByteString.Lazy as LB
import Data.Char (toLower)
import Data.List (sort)
import Data.Maybe (mapMaybe)
import Data.Text.Encoding (decodeUtf8)
- Step 2: Modify
SortMode— keep the type but it won't be used for routing anymore
No changes needed to the SortMode type itself. It can remain for clarity — we'll just stop routing on it server-side (that's Task 3).
- Step 3: Rewrite
indexPageto produce the shell page
Replace the current indexPage with one that:
- Builds the JSON payloads
- Renders the page shell (nav, search input, JSON script tags, JS script, empty container for the recipe list)
-- | Render the recipe index page — shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
indexPage _mode recipes =
page "Roux — Recipes" $ do
H.div ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.strong "Roux"
H.ul $ do
H.li $ H.a ! A.href "#alpha" $ "A-Z"
H.li $ H.a ! A.href "#tags" $ "By Tag"
H.li $ H.a ! A.href "#course" $ "By Course"
-- Search input
H.input
! A.type_ "search"
! A.id "roux-search"
! A.placeholder "Search recipes…"
! A.style "margin-bottom: 1rem;"
-- Container for JS-rendered recipe list
H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes…"
-- Embedded JSON: recipe data
H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (encode (map Idx.toSearchEntry (filterOk recipes)))))
-- Embedded JSON: error data
H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (encode (map Idx.toSearchEntry (filterBad recipes)))))
-- Inline JS
H.script ! A.type_ "text/javascript" $ H.toHtml searchJs
where
filterOk = filter (isRight . Idx.riRecipe)
filterBad = filter (isLeft . Idx.riRecipe)
Note: Data.Aeson.encode returns a lazy ByteString. decodeUtf8 expects a strict ByteString, so we use LB.toStrict to convert. Both Data.ByteString.Lazy (qualified as LB) and Data.Text.Encoding are imported above.
import Data.Text.Encoding (decodeUtf8)
The old helper functions like sortLink, renderAlpha, renderByTags, renderByCourse, recipeItem, alphaSortKey, renderTagGroup, renderCourseGroup should be removed since they're no longer used. Keep takeBaseName, nubOrd, isLeft, isRight since they may be used elsewhere (check).
- Step 4: Write the inline JavaScript
Add this function returning the JS string. Place it after the imports / before indexPage:
-- | Inline JavaScript for client-side search + sort rendering.
searchJs :: Text
searchJs = T.unlines
[ "(function(){"
, "'use strict';"
, ""
, "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);"
, "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);"
, "const listEl = document.getElementById('roux-recipe-list');"
, "const searchEl = document.getElementById('roux-search');"
, ""
, "function getSortMode() {"
, " const h = location.hash.slice(1);"
, " if (h === 'tags') return 'tags';"
, " if (h === 'course') return 'course';"
, " return 'alpha';"
, "}"
, ""
, "function render() {"
, " const query = searchEl.value.toLowerCase().trim();"
, " const mode = getSortMode();"
, " let filtered = recipes;"
, " if (query) {"
, " filtered = recipes.filter(r =>"
, " r.title.toLowerCase().includes(query) ||"
, " r.filename.toLowerCase().includes(query)"
, " );"
, " }"
, " let html = '';"
, " if (query || mode === 'alpha') {"
, " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));"
, " html = renderFlatList(sorted);"
, " } else if (mode === 'tags') {"
, " html = renderByTags(filtered);"
, " } else if (mode === 'course') {"
, " html = renderByCourse(filtered);"
, " }"
, " if (errors.length > 0) {"
, " html += '<h3>Unparseable recipes</h3><ul>';"
, " errors.forEach(e => { html += '<li>' + escapeHtml(e.title) + '</li>'; });"
, " html += '</ul>';"
, " }"
, " listEl.innerHTML = html;"
, "}"
, ""
, "function renderFlatList(items) {"
, " if (items.length === 0) return '<p>No recipes found.</p>';"
, " let html = '<ul class=\\'roux-recipes\\'>';"
, " items.forEach(r => {"
, " html += '<li><a href=\"/recipes/' + encodeURI(r.filename) + '\">' + escapeHtml(r.title) + '</a></li>';"
, " });"
, " html += '</ul>';"
, " return html;"
, "}"
, ""
, "function renderByTags(items) {"
, " const tagMap = {};"
, " items.forEach(r => {"
, " if (r.tags.length === 0) return;"
, " r.tags.forEach(t => {"
, " if (!tagMap[t]) tagMap[t] = [];"
, " tagMap[t].push(r);"
, " });"
, " });"
, " const tags = Object.keys(tagMap).sort((a,b) => a.localeCompare(b));"
, " if (tags.length === 0) return '<p>No tagged recipes.</p>';"
, " let html = '';"
, " tags.forEach(t => {"
, " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));"
, " html += '<h3>' + escapeHtml(t) + '</h3>';"
, " html += renderFlatList(sorted);"
, " });"
, " return html;"
, "}"
, ""
, "function renderByCourse(items) {"
, " const courseMap = {};"
, " items.forEach(r => {"
, " if (!r.course) return;"
, " if (!courseMap[r.course]) courseMap[r.course] = [];"
, " courseMap[r.course].push(r);"
, " });"
, " const courses = Object.keys(courseMap).sort((a,b) => a.localeCompare(b));"
, " if (courses.length === 0) return '<p>No recipes with course metadata.</p>';"
, " let html = '';"
, " courses.forEach(c => {"
, " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));"
, " html += '<h3>' + escapeHtml(c) + '</h3>';"
, " html += renderFlatList(sorted);"
, " });"
, " return html;"
, "}"
, ""
, "function escapeHtml(s) {"
, " const d = document.createElement('div');"
, " d.appendChild(document.createTextNode(s));"
, " return d.innerHTML;"
, "}"
, ""
, "searchEl.addEventListener('input', render);"
, "window.addEventListener('hashchange', render);"
, ""
, "// Set active tab style based on hash"
, "function updateActiveNav() {"
, " const mode = getSortMode();"
, " document.querySelectorAll('.roux-navbar a').forEach(a => {"
, " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);"
, " });"
, "}"
, "window.addEventListener('hashchange', updateActiveNav);"
, ""
, "// Initial render"
, "if (!location.hash) location.hash = 'alpha';"
, "updateActiveNav();"
, "render();"
, ""
, "})();"
]
- Step 5: Clean up unused functions and verify
isRight,isLeftare still used
Check if isRight and isLeft are used anywhere outside the now-removed indexPage rendering. If Html.hs only uses them in indexPage, remove them from this module too (they're defined at the bottom). Actually, they're used in indexPage via filterOk/filterBad. Let me keep isRight and isLeft — they're needed. But keep an eye out for unused functions.
The functions to remove from Html.hs:
sortLink— no longer usedrenderAlpha— no longer usedrenderByTags— no longer usedrenderByCourse— no longer usedrecipeItem— no longer usedrenderTagGroup— no longer usedrenderCourseGroup— no longer usedalphaSortKey— no longer used
Functions to keep:
-
page— still used byindexPageandrecipePage -
searchJs— new, used byindexPage -
isLeft,isRight— still used byindexPage'sfilterOk/filterBad -
nubOrd— check if used anywhere else. If not, remove. -
takeBaseName— check if used elsewhere. If not, remove. -
Step 6: Build to verify compilation
Run: ./hs stack build
Expected: Compiles successfully.
- Step 7: Commit
git add src/Roux/Html.hs
git commit -m "feat: replace server-rendered index with JS-rendered shell page"
Task 3: Simplify routes in Server.hs
Files:
-
Modify:
src/Roux/Server.hs -
Step 1: Remove sort-mode routing
The current routes are:
[] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipes))
["sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.TagSort recipes))
["sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.CourseSort recipes))
Simplify: all three routes serve the same indexPage (the sort mode is now driven by URL hash on the client side). The SortMode argument to indexPage can be any value since it's ignored — use AlphaSort for all:
[] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipes))
["sorted", "tags"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipes))
["sorted", "course"] -> respond (htmlResponse (Html.indexPage Html.AlphaSort recipes))
- Step 2: Build to verify
Run: ./hs stack build
Expected: Compiles successfully.
- Step 3: Commit
git add src/Roux/Server.hs
git commit -m "refactor: serve same index page for all sort routes (hash-driven client-side)"
Task 4: Build, test, and verify
Files:
-
Build project, run tests, start server, manual check
-
Step 1: Full build
Run: ./hs stack build
Expected: Compiles with no warnings or errors.
- Step 2: Run existing tests
Run: ./hs stack test
Expected: All existing parser/NYTimes tests pass.
- Step 3: Manual verification with the example recipes
Start the server with example recipes:
cd /work/personal/roux/roux-main && ./scripts/run ./cooklang-examples --port 9090 &
Wait for startup, then:
curl -s http://127.0.0.1:9090/ | grep -c "roux-recipe-data"
Expected: 1 (JSON data script tag present)
curl -s http://127.0.0.1:9090/ | grep -c "roux-recipe-list"
Expected: 1 (recipe list container present)
curl -s http://127.0.0.1:9090/ | grep -c "<script"
Expected: At least 2 (JSON data script + JS script)
Kill the server: kill %1
- Step 4: Verify hash-based URLs return the same page
diff <(curl -s http://127.0.0.1:9090/) <(curl -s http://127.0.0.1:9090/sorted/tags)
Expected: No output (identical pages)
- Step 5: Commit
git commit -m "chore: build and test passes for search feature"
Task 5: Fix file ownership if needed
- Step 1: Check ownership of any agent-created files
find /work/personal/roux/roux-main/src /work/personal/roux/roux-main/test -user root -ls 2>/dev/null
- Step 2: Fix ownership if root-owned files exist
sudo chown -R $(id -u):$(id -g) /work/personal/roux/roux-main