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 ) where
import Control.Monad (unless) import Control.Monad (unless)
import Data.Aeson (encode)
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Function ((&)) import Data.Function ((&))
import Data.List (sortOn)
import qualified Data.List.NonEmpty as NE 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.Ratio (denominator, numerator)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
import qualified Text.Blaze.Html.Renderer.Utf8 as R import qualified Text.Blaze.Html.Renderer.Utf8 as R
import Text.Blaze.Html5 as H import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A import Text.Blaze.Html5.Attributes as A
@@ -204,115 +206,155 @@ page title content =
-- Index page -- 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 :: SortMode -> [Idx.RecipeInfo] -> ByteString
indexPage mode recipes = indexPage _mode recipes =
page "Roux — Recipes" $ do page "Roux — Recipes" $ do
H.div ! A.class_ "roux-navbar" $ do H.div ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.strong "Roux" H.ul $ H.li $ H.strong "Roux"
H.ul $ do H.ul $ do
H.li $ sortLink "A-Z" AlphaSort "/" mode H.li $ H.a ! A.href "#alpha" $ "A-Z"
H.li $ sortLink "By Tag" TagSort "/sorted/tags" mode H.li $ H.a ! A.href "#tags" $ "By Tag"
H.li $ sortLink "By Course" CourseSort "/sorted/course" mode H.li $ H.a ! A.href "#course" $ "By Course"
let good = filter (isRight . Idx.riRecipe) recipes -- Search input
case mode of H.input
AlphaSort -> renderAlpha good ! A.type_ "search"
TagSort -> renderByTags good ! A.id "roux-search"
CourseSort -> renderByCourse good ! A.placeholder "Search recipes..."
-- Show parse errors separately ! A.style "margin-bottom: 1rem;"
let bad = filter (isLeft . Idx.riRecipe) recipes -- Container for JS-rendered recipe list
unless (null bad) $ do H.div ! A.id "roux-recipe-list" $ H.p "Loading recipes..."
H.h3 "Unparseable recipes" -- Embedded JSON: recipe data
H.ul $ mapM_ (H.li . H.toHtml . Idx.riTitle) bad H.script ! A.id "roux-recipe-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterOk recipes)))))
-- | A navigation link for a sort mode, highlighted when active. -- Embedded JSON: error data
sortLink :: Text -> SortMode -> Text -> SortMode -> Html H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
sortLink label linkMode url currentMode = H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
if linkMode == currentMode -- Inline JS
then H.a ! A.href (H.toValue url) ! A.role "button" ! A.class_ "contrast outline" $ H.toHtml label H.script ! A.type_ "text/javascript" $ H.toHtml searchJs
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
where where
collectTags rs = filterOk = filter (isRight . Idx.riRecipe)
let entries = mapMaybe tagEntries rs filterBad = filter (isLeft . Idx.riRecipe)
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
-- | 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 :: Idx.RecipeInfo -> ByteString
recipePage info = recipePage info =
case Idx.riRecipe info of case Idx.riRecipe info of
@@ -585,10 +627,6 @@ urlDecode t = concat (go [] (T.group t))
-- Misc helpers -- Misc helpers
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- | Extract base filename without directory components.
takeBaseName :: FilePath -> String
takeBaseName = reverse . takeWhile (/= '/') . reverse
-- | Check if an 'Either' is 'Left'. -- | Check if an 'Either' is 'Left'.
isLeft :: Either a b -> Bool isLeft :: Either a b -> Bool
isLeft (Left _) = True isLeft (Left _) = True
@@ -598,13 +636,4 @@ isLeft _ = False
isRight :: Either a b -> Bool isRight :: Either a b -> Bool
isRight = not . isLeft 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