diff --git a/docs/plans/2026-05-19-typeahead-search.md b/docs/plans/2026-05-19-typeahead-search.md new file mode 100644 index 0000000..20625d9 --- /dev/null +++ b/docs/plans/2026-05-19-typeahead-search.md @@ -0,0 +1,413 @@ +# 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 `aeson` import and `RecipeSearchEntry` data type** + +Add a new data type for the JSON payload — a lightweight projection of `RecipeInfo` containing only the fields the JS needs. + +```haskell +-- | 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 `toSearchEntry` conversion function** + +```haskell +-- | 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: + +```haskell +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** + +```bash +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: + +```haskell +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 `indexPage` to produce the shell page** + +Replace the current `indexPage` with one that: +1. Builds the JSON payloads +2. Renders the page shell (nav, search input, JSON script tags, JS script, empty container for the recipe list) + +```haskell +-- | 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. + +```haskell +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`: + +```haskell +-- | 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 += '

Unparseable recipes

';" + , " }" + , " listEl.innerHTML = html;" + , "}" + , "" + , "function renderFlatList(items) {" + , " if (items.length === 0) return '

No recipes found.

';" + , " let html = '';" + , " 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 '

No tagged recipes.

';" + , " let html = '';" + , " tags.forEach(t => {" + , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));" + , " html += '

' + escapeHtml(t) + '

';" + , " 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 '

No recipes with course metadata.

';" + , " let html = '';" + , " courses.forEach(c => {" + , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));" + , " html += '

' + escapeHtml(c) + '

';" + , " 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`, `isLeft` are 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 used +- `renderAlpha` — no longer used +- `renderByTags` — no longer used +- `renderByCourse` — no longer used +- `recipeItem` — no longer used +- `renderTagGroup` — no longer used +- `renderCourseGroup` — no longer used +- `alphaSortKey` — no longer used + +Functions to keep: +- `page` — still used by `indexPage` and `recipePage` +- `searchJs` — new, used by `indexPage` +- `isLeft`, `isRight` — still used by `indexPage`'s `filterOk`/`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** + +```bash +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: +```haskell +[] -> 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: + +```haskell +[] -> 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** + +```bash +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: + +```bash +cd /work/personal/roux/roux-main && ./scripts/run ./cooklang-examples --port 9090 & +``` + +Wait for startup, then: +```bash +curl -s http://127.0.0.1:9090/ | grep -c "roux-recipe-data" +``` +Expected: 1 (JSON data script tag present) + +```bash +curl -s http://127.0.0.1:9090/ | grep -c "roux-recipe-list" +``` +Expected: 1 (recipe list container present) + +```bash +curl -s http://127.0.0.1:9090/ | grep -c "/dev/null +``` + +- [ ] **Step 2: Fix ownership if root-owned files exist** + +```bash +sudo chown -R $(id -u):$(id -g) /work/personal/roux/roux-main +```