chore: add implementation plan for type-ahead search
This commit is contained in:
@@ -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 += '<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`, `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 "<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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
sudo chown -R $(id -u):$(id -g) /work/personal/roux/roux-main
|
||||
```
|
||||
Reference in New Issue
Block a user