feat: replace server-rendered index with JS-rendered shell page

This commit is contained in:
2026-05-19 21:35:52 -04:00
parent 632f61820b
commit 954bad87bd
+147 -118
View File
@@ -16,14 +16,16 @@ module Roux.Html (
) where
import Control.Monad (unless)
import Data.Aeson (encode)
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Function ((&))
import Data.List (sortOn)
import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
import Data.Maybe (catMaybes, fromMaybe)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
import qualified Text.Blaze.Html.Renderer.Utf8 as R
import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A
@@ -204,115 +206,155 @@ page title content =
-- Index page
-- ---------------------------------------------------------------------------
-- | Render the recipe index page.
-- | 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=\"' + 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();"
, ""
, "})();"
]
-- | Render the recipe index page -- shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
indexPage mode recipes =
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 $ sortLink "A-Z" AlphaSort "/" mode
H.li $ sortLink "By Tag" TagSort "/sorted/tags" mode
H.li $ sortLink "By Course" CourseSort "/sorted/course" mode
let good = filter (isRight . Idx.riRecipe) recipes
case mode of
AlphaSort -> renderAlpha good
TagSort -> renderByTags good
CourseSort -> renderByCourse good
-- Show parse errors separately
let bad = filter (isLeft . Idx.riRecipe) recipes
unless (null bad) $ do
H.h3 "Unparseable recipes"
H.ul $ mapM_ (H.li . H.toHtml . Idx.riTitle) bad
-- | A navigation link for a sort mode, highlighted when active.
sortLink :: Text -> SortMode -> Text -> SortMode -> Html
sortLink label linkMode url currentMode =
if linkMode == currentMode
then H.a ! A.href (H.toValue url) ! A.role "button" ! A.class_ "contrast outline" $ H.toHtml label
else H.a ! A.href (H.toValue url) $ H.toHtml label
-- | Render recipes sorted alphabetically by title.
renderAlpha :: [Idx.RecipeInfo] -> Html
renderAlpha recipes
| null recipes = H.p "No recipes found."
| otherwise =
let sorted = sortOn alphaSortKey recipes
in H.ul ! A.class_ "roux-recipes" $ mapM_ recipeItem sorted
-- | Render recipes grouped by tags.
renderByTags :: [Idx.RecipeInfo] -> Html
renderByTags recipes = do
let tags = collectTags recipes
if null tags
then H.p "No tagged recipes."
else mapM_ renderTagGroup tags
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 (Prelude.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 (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
-- Inline JS
H.script ! A.type_ "text/javascript" $ H.toHtml searchJs
where
collectTags rs =
let entries = mapMaybe tagEntries rs
allTags = concat entries
uniqueTags = sortOn Prelude.id (nubOrd allTags)
in [(tag, filter (hasTag tag) rs) | tag <- uniqueTags]
tagEntries r = case Idx.riRecipe r of
Right recipe ->
let tags = metaTags (recipeMetadata recipe)
in if null tags then Nothing else Just tags
Left _ -> Nothing
hasTag tag r = case Idx.riRecipe r of
Right recipe -> tag `elem` metaTags (recipeMetadata recipe)
Left _ -> False
filterOk = filter (isRight . Idx.riRecipe)
filterBad = filter (isLeft . Idx.riRecipe)
-- | Render recipes grouped by course/category.
renderByCourse :: [Idx.RecipeInfo] -> Html
renderByCourse recipes = do
let groups = collectCourses recipes
if null groups
then H.p "No recipes with course metadata."
else mapM_ renderCourseGroup groups
where
collectCourses rs =
let entries = mapMaybe courseEntry rs
uniqueCourses = sortOn Prelude.id (nubOrd (Prelude.map fst entries))
in [(course, [r | r <- rs, courseOf r == Just course]) | course <- uniqueCourses]
courseEntry r = case Idx.riRecipe r of
Right recipe -> (,) <$> metaCourse (recipeMetadata recipe) <*> pure ()
Left _ -> Nothing
courseOf r = case Idx.riRecipe r of
Right recipe -> metaCourse (recipeMetadata recipe)
Left _ -> Nothing
-- | Render a group of recipes under a tag heading.
renderTagGroup :: (Text, [Idx.RecipeInfo]) -> Html
renderTagGroup (tag, rs) = do
H.h3 $ H.toHtml tag
H.ul ! A.class_ "roux-recipes" $ mapM_ recipeItem rs
-- | Render a group of recipes under a course heading.
renderCourseGroup :: (Text, [Idx.RecipeInfo]) -> Html
renderCourseGroup (course, rs) = do
H.h3 $ H.toHtml course
H.ul ! A.class_ "roux-recipes" $ mapM_ recipeItem rs
-- | Render a single recipe link on the index page.
recipeItem :: Idx.RecipeInfo -> Html
recipeItem info =
H.li $
H.a ! A.href (H.toValue ("/recipes/" <> urlEncode (Idx.riFilename info))) $
H.toHtml (Idx.riTitle info)
-- | Sort key for alphabetical ordering by title, falling back to filename.
alphaSortKey :: Idx.RecipeInfo -> Text
alphaSortKey info = case Idx.riRecipe info of
Right r -> case metaTitle (recipeMetadata r) of
Just t -> t
Nothing -> T.pack (takeBaseName (Idx.riFilename info))
Left _ -> T.pack "zzzz"
-- ---------------------------------------------------------------------------
-- Recipe detail page
-- ---------------------------------------------------------------------------
-- | Render a full recipe detail page.
recipePage :: Idx.RecipeInfo -> ByteString
recipePage info =
case Idx.riRecipe info of
@@ -585,10 +627,6 @@ urlDecode t = concat (go [] (T.group t))
-- Misc helpers
-- ---------------------------------------------------------------------------
-- | Extract base filename without directory components.
takeBaseName :: FilePath -> String
takeBaseName = reverse . takeWhile (/= '/') . reverse
-- | Check if an 'Either' is 'Left'.
isLeft :: Either a b -> Bool
isLeft (Left _) = True
@@ -598,13 +636,4 @@ isLeft _ = False
isRight :: Either a b -> Bool
isRight = not . isLeft
{- | Remove duplicate elements from a list while preserving order of first
occurrence.
-}
nubOrd :: (Eq a) => [a] -> [a]
nubOrd = go []
where
go _ [] = []
go seen (x : xs)
| x `elem` seen = go seen xs
| otherwise = x : go (x : seen) xs