Recipe detail page with server-side HTML rendering

- Add Roux.Html.recipePage: full recipe detail page with:
  - Back navigation link
  - Title and metadata (servings, times, difficulty, etc.)
  - Ingredients summary list with quantities
  - Sections with named headings
  - Steps with inline element rendering:
    - Ingredients highlighted in pumpkin (with quantity badge)
    - Cookware in violet italic
    - Timers in azure with ⏱ icon
    - Comments in sand italic
    - Recipe refs as links
    - Line breaks
- Update Roux.Server: route /recipes/FILENAME to recipe page
  - Case-insensitive filename lookup, .cook extension optional
  - URL decoding for filenames
- Fix ingredient name parsing: restrict name characters to
  alphaNum + spaces/hyphens/apostrophes, preventing greedy
  consumption across special chars like ) and #
- Split name chars into pMultiNameChar (with spaces, before braces)
  and pSingleNameChar (without spaces, fallback)
- Suppress -Wname-shadowing in Html.hs (blaze-html exports clash
  with common variable names)
- All 26 tests passing, server smoke-tested with examples
This commit is contained in:
2026-05-19 07:12:14 -04:00
parent 4943d4d42e
commit b775fd4f3f
3 changed files with 339 additions and 30 deletions
+294 -22
View File
@@ -1,12 +1,25 @@
{-# OPTIONS_GHC -Wno-name-shadowing #-}
{- | HTML rendering for the Roux web interface. {- | HTML rendering for the Roux web interface.
Uses blaze-html directly. Styling via Pico CSS (inlined in the page). Uses blaze-html directly. Styling via Pico CSS.
Blaze-html re-exports many common names (title, meta, step, a, b, …)
so name-shadowing is unavoidable with local bindings.
-} -}
module Roux.Html ( module Roux.Html (
indexPage, indexPage,
recipePage,
urlEncode,
urlDecode,
) where ) where
import Control.Monad (unless)
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import Data.Function ((&))
import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes)
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 qualified Text.Blaze.Html.Renderer.Utf8 as R import qualified Text.Blaze.Html.Renderer.Utf8 as R
@@ -14,35 +27,266 @@ import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A import Text.Blaze.Html5.Attributes as A
import qualified Roux.RecipeIndex as Idx import qualified Roux.RecipeIndex as Idx
import Roux.Types
-- | Render the recipe index page. -- ---------------------------------------------------------------------------
indexPage :: [Idx.RecipeInfo] -> ByteString -- Helpers used throughout
indexPage recipes = -- ---------------------------------------------------------------------------
-- | Pair a label with a value if present.
pairWith :: a -> Maybe b -> Maybe (a, b)
pairWith _ Nothing = Nothing
pairWith a (Just b) = Just (a, b)
-- | Format a quantity for display.
showQuantity :: Quantity -> Text
showQuantity q =
let amt = quantityAmount q
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> T.pack (show (toDouble amt))
prefix = if quantityFixed q then "=" else ""
unit = maybe "" (\u -> "%" <> u) (quantityUnit q)
in prefix <> amount <> unit
-- ---------------------------------------------------------------------------
-- Page shell
-- ---------------------------------------------------------------------------
-- | Wrap content in a full HTML5 page with Pico CSS.
page :: Text -> Html -> ByteString
page title content =
R.renderHtml $ R.renderHtml $
H.docTypeHtml $ do H.docTypeHtml $ do
H.head $ do H.head $ do
H.meta ! A.charset "utf-8" H.meta ! A.charset "utf-8"
H.meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1" H.meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1"
H.title "Roux — Recipes" H.title (H.toHtml title)
H.link ! A.rel "stylesheet" ! A.href "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" H.link ! A.rel "stylesheet" ! A.href "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
H.body $ do H.style
H.main ! A.class_ "container" $ do "main.container { padding-top: 2rem; } \
H.h1 "Roux" \.roux-ingredient { color: var(--pico-color-pumpkin); } \
H.p "Your personal recipe collection." \.roux-cookware { color: var(--pico-color-violet); font-style: italic; } \
H.h2 "Recipes" \.roux-timer { color: var(--pico-color-azure); } \
H.ul $ mapM_ recipeItem recipes \.roux-comment { color: var(--pico-color-sand); font-style: italic; } \
\.roux-break { display: block; } \
\.tag { display: inline-block; background: var(--pico-contrast-background); \
\padding: 0.1rem 0.5rem; margin: 0.1rem; border-radius: 4px; font-size: 0.8rem; }"
H.body $ H.main ! A.class_ "container" $ content
-- | Render a single recipe list item. -- ---------------------------------------------------------------------------
recipeItem :: Idx.RecipeInfo -> Html -- Index page
recipeItem info = -- ---------------------------------------------------------------------------
H.li $ case Idx.riRecipe info of
Left _err -> -- | Render the recipe index page.
H.span ! A.class_ "error" $ do indexPage :: [Idx.RecipeInfo] -> ByteString
H.toHtml (Idx.riTitle info) indexPage recipes =
" (parse error)" page "Roux — Recipes" $ do
Right _recipe -> H.nav $ H.ul $ H.li $ H.strong "Roux"
H.a ! A.href (H.toValue ("/recipes/" <> urlEncode (Idx.riFilename info))) $ H.h2 "Recipes"
H.toHtml (Idx.riTitle info) H.p "Your personal recipe collection."
case filter (isRight . Idx.riRecipe) recipes of
[] -> H.p "No recipes found."
good ->
H.ul $ mapM_ (recipeItem . Idx.riFilename) good
-- Show parse errors separately
let bad = filter (isLeft . Idx.riRecipe) recipes
unless (null bad) $ do
H.h3 "Unparseable recipes"
H.ul $ mapM_ (\r -> H.li $ H.toHtml (Idx.riTitle r)) bad
-- | Render a single recipe link on the index page.
recipeItem :: FilePath -> Html
recipeItem fp =
H.li $
H.a ! A.href (H.toValue ("/recipes/" <> urlEncode fp)) $
H.toHtml (T.pack (takeBaseName fp))
-- ---------------------------------------------------------------------------
-- Recipe detail page
-- ---------------------------------------------------------------------------
-- | Render a full recipe detail page.
recipePage :: Idx.RecipeInfo -> ByteString
recipePage info =
case Idx.riRecipe info of
Left err ->
page "Parse Error" $ do
H.nav $ H.ul $ H.li $ H.a ! A.href "/" $ "← Back"
H.h2 "Parse Error"
H.p $ H.toHtml (Idx.riTitle info <> ": " <> T.pack err)
Right recipe ->
page (titleText recipe) $
renderRecipe recipe
-- | Render the body of a recipe page (no page shell).
renderRecipe :: Recipe -> Html
renderRecipe recipe = do
H.nav $ H.ul $ H.li $ H.a ! A.href "/" $ "← Back to index"
H.h1 $ H.toHtml (titleText recipe)
renderMetadata (recipeMetadata recipe)
let allIngredients = collectIngredients recipe
unless (null allIngredients) $ do
H.h2 "Ingredients"
H.ul $ mapM_ renderIngredientItem allIngredients
H.hr
NE.toList (recipeSections recipe) & mapM_ renderSection
-- | Extract display title from recipe metadata or fallback.
titleText :: Recipe -> Text
titleText recipe =
case metaTitle (recipeMetadata recipe) of
Just t -> t
Nothing -> "Untitled Recipe"
-- ---------------------------------------------------------------------------
-- Metadata
-- ---------------------------------------------------------------------------
-- | Render recipe metadata as a description list.
renderMetadata :: Metadata -> Html
renderMetadata meta = do
let rows =
catMaybes
[ pairWith (T.pack "Servings") (showServings <$> metaServings meta)
, pairWith (T.pack "Total time") (showDuration <$> metaTotalTime meta)
, pairWith (T.pack "Prep time") (showDuration <$> metaPrepTime meta)
, pairWith (T.pack "Cook time") (showDuration <$> metaCookTime meta)
, pairWith (T.pack "Difficulty") (metaDifficulty meta)
, pairWith (T.pack "Cuisine") (metaCuisine meta)
, pairWith (T.pack "Author") (metaAuthor meta)
, pairWith (T.pack "Source") (metaSource meta)
]
unless (null rows) $
H.dl $
mapM_ (\(label, value) -> H.dt (H.toHtml label) >> H.dd (H.toHtml value)) rows
-- Tags as badges
let tags = metaTags meta
unless (null tags) $
H.p $ do
"Tags: "
mapM_ (\t -> H.span ! A.class_ "tag" $ H.toHtml t) tags
-- | Format servings info.
showServings :: (Int, Maybe Text) -> Text
showServings (n, Nothing) = T.pack (show n)
showServings (n, Just unit) = T.pack (show n) <> " " <> unit
-- | Format a duration.
showDuration :: Duration -> Text
showDuration d =
let amt = durationAmount d
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> T.pack (show (toDouble amt))
unit = maybe "" Prelude.id (durationUnit d)
in T.strip (amount <> " " <> unit)
-- | Roughly convert Rational to Double for display.
toDouble :: Rational -> Double
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
-- ---------------------------------------------------------------------------
-- Sections
-- ---------------------------------------------------------------------------
-- | Render one section of a recipe.
renderSection :: Section -> Html
renderSection section = do
case sectionName section of
Just name -> H.h3 $ H.toHtml name
Nothing -> pure ()
NE.toList (sectionBody section) & mapM_ renderBodyItem
-- | Render one body item (step, comment, or note).
renderBodyItem :: SectionBodyItem -> Html
renderBodyItem (SecStep step) =
H.p $ mapM_ renderStepItem (unStep step)
renderBodyItem (SecComment t) =
H.blockquote $ H.em $ H.toHtml ("-- " <> t)
renderBodyItem (SecNote t) =
H.blockquote $ H.small $ H.toHtml ("> " <> t)
-- ---------------------------------------------------------------------------
-- Step items
-- ---------------------------------------------------------------------------
-- | Render one inline element within a step.
renderStepItem :: StepItem -> Html
renderStepItem (StepText t) = H.toHtml t
renderStepItem (StepIngredient ing) =
H.mark ! A.class_ "roux-ingredient" $ do
H.toHtml (ingName ing)
case ingQuantity ing of
Nothing -> pure ()
Just q -> do
" "
H.small $ "[" <> H.toHtml (showQuantity q) <> "]"
renderStepItem (StepCookware cw) =
H.span ! A.class_ "roux-cookware" $
H.toHtml (cwName cw)
renderStepItem (StepTimer timer) =
H.span ! A.class_ "roux-timer" $ do
case timerName timer of
Just n -> H.toHtml n >> " "
Nothing -> pure ()
H.small $ "" <> H.toHtml (showDuration (timerDuration timer))
renderStepItem (StepRecipeRef ref) =
H.a ! A.href (H.toValue ("/recipes/" <> urlEncode (refPath ref <> ".cook"))) $
H.toHtml (refPath ref)
renderStepItem (StepEndComment t) =
H.span ! A.class_ "roux-comment" $ H.em $ H.toHtml (" -- " <> t)
renderStepItem (StepComment t) =
H.span ! A.class_ "roux-comment" $ H.em $ H.toHtml ("[- " <> t <> " -]")
renderStepItem StepBreak =
H.br ! A.class_ "roux-break"
-- | Render one ingredient in the ingredients summary list.
renderIngredientItem :: (Text, Maybe Quantity) -> Html
renderIngredientItem (name, qty) =
H.li $ do
H.toHtml name
case qty of
Nothing -> pure ()
Just q -> do
""
H.small $ H.toHtml (showQuantity q)
-- ---------------------------------------------------------------------------
-- Ingredients collection
-- ---------------------------------------------------------------------------
{- | Collect all unique ingredients from a recipe with their first-seen
quantity.
-}
collectIngredients :: Recipe -> [(Text, Maybe Quantity)]
collectIngredients recipe =
let items =
recipe
& recipeSections
& NE.toList
>>= NE.toList . sectionBody
>>= bodyItemSteps
>>= unStep
ings = [i | StepIngredient i <- items]
in dedupFirst ings []
-- | Deduplicate ingredients by name, keeping first occurrence.
dedupFirst :: [Ingredient] -> [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)]
dedupFirst [] acc = reverse acc
dedupFirst (i : rest) acc =
if any ((== ingName i) . fst) acc
then dedupFirst rest acc
else dedupFirst rest ((ingName i, ingQuantity i) : acc)
-- | Extract step items from a body item (returns empty for comments/notes).
bodyItemSteps :: SectionBodyItem -> [Step]
bodyItemSteps (SecStep s) = [s]
bodyItemSteps _ = []
-- ---------------------------------------------------------------------------
-- URL encoding
-- ---------------------------------------------------------------------------
-- | Percent-encode a filename for use in a URL path segment. -- | Percent-encode a filename for use in a URL path segment.
urlEncode :: FilePath -> Text urlEncode :: FilePath -> Text
@@ -53,3 +297,31 @@ urlEncode = T.pack . concatMap encodeChar
| c == '#' = "%23" | c == '#' = "%23"
| c == '%' = "%25" | c == '%' = "%25"
| otherwise = [c] | otherwise = [c]
-- | Percent-decode a URL path segment back to a filename.
urlDecode :: Text -> FilePath
urlDecode t = concat (go [] (T.group t))
where
go acc [] = reverse acc
go acc (s : rest)
| s == "%20" = go (" " : acc) rest
| s == "%23" = go ("#" : acc) rest
| s == "%25" = go ("%" : acc) rest
| otherwise = go (T.unpack s : acc) rest
-- ---------------------------------------------------------------------------
-- 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
isLeft _ = False
-- | Check if an 'Either' is 'Right'.
isRight :: Either a b -> Bool
isRight = not . isLeft
+17 -5
View File
@@ -128,6 +128,10 @@ pLineBreak = do
_ <- P.newline _ <- P.newline
return StepBreak return StepBreak
-- | Characters allowed in recipe reference paths.
pPathChar :: Parser Char
pPathChar = P.alphaNum P.<|> P.oneOf "./-_"
-- | Recipe reference: @./path/to/recipe{quantity} -- | Recipe reference: @./path/to/recipe{quantity}
pRecipeRef :: Parser StepItem pRecipeRef :: Parser StepItem
pRecipeRef = do pRecipeRef = do
@@ -135,7 +139,7 @@ pRecipeRef = do
-- Consume @ and store ./ as part of the path -- Consume @ and store ./ as part of the path
_ <- P.char '.' _ <- P.char '.'
_ <- P.char '/' _ <- P.char '/'
path <- P.many (P.noneOf "{(\n\\") path <- P.many pPathChar
mqty <- P.optionMaybe (P.between (P.char '{') (P.char '}') pQuantityBody) mqty <- P.optionMaybe (P.between (P.char '{') (P.char '}') pQuantityBody)
return (StepRecipeRef (RecipeRef ("./" <> path) (join mqty))) return (StepRecipeRef (RecipeRef ("./" <> path) (join mqty)))
@@ -147,6 +151,14 @@ pIngredient = do
prep <- P.optionMaybe (P.try pPreparation) prep <- P.optionMaybe (P.try pPreparation)
return (StepIngredient (Ingredient (T.pack name) qty (fmap T.strip prep))) return (StepIngredient (Ingredient (T.pack name) qty (fmap T.strip prep)))
-- | Characters allowed in names that may include spaces (before braces).
pMultiNameChar :: Parser Char
pMultiNameChar = P.alphaNum P.<|> P.oneOf " .-'"
-- | Characters allowed in single-word names (no braces).
pSingleNameChar :: Parser Char
pSingleNameChar = P.alphaNum P.<|> P.oneOf ".-'"
{- | Parse a name optionally followed by @{quantity}@ braces. {- | Parse a name optionally followed by @{quantity}@ braces.
Used by ingredients. First tries a multi-word name with braces Used by ingredients. First tries a multi-word name with braces
@@ -156,14 +168,14 @@ pNameWithQuantity :: Parser ([Char], Maybe Quantity)
pNameWithQuantity = pNameWithQuantity =
P.try P.try
( do ( do
name <- P.many (P.noneOf "{(\n\\") name <- P.many pMultiNameChar
_ <- P.char '{' _ <- P.char '{'
qty <- pQuantityBody qty <- pQuantityBody
_ <- P.char '}' _ <- P.char '}'
return (name, qty) return (name, qty)
) )
P.<|> do P.<|> do
name <- P.many1 (P.alphaNum P.<|> P.oneOf ".-'") name <- P.many1 pSingleNameChar
return (name, Nothing) return (name, Nothing)
{- | Parse a name optionally followed by empty @{}@ braces. {- | Parse a name optionally followed by empty @{}@ braces.
@@ -174,12 +186,12 @@ pNameWithBraces :: Parser [Char]
pNameWithBraces = pNameWithBraces =
P.try P.try
( do ( do
name <- P.many (P.noneOf "{(\n\\") name <- P.many pMultiNameChar
_ <- P.char '{' _ <- P.char '{'
_ <- P.char '}' _ <- P.char '}'
return name return name
) )
P.<|> P.many1 (P.alphaNum P.<|> P.oneOf ".-'") P.<|> P.many1 pSingleNameChar
-- | Parse preparation text inside parentheses: @name(diced) -- | Parse preparation text inside parentheses: @name(diced)
pPreparation :: Parser Text pPreparation :: Parser Text
+28 -3
View File
@@ -1,7 +1,8 @@
{-# LANGUAGE OverloadedStrings #-}
{- | WAI application for the Roux recipe server. {- | WAI application for the Roux recipe server.
Handles HTTP requests directly via wai — no framework. Currently serves Handles HTTP requests directly via wai — no framework.
a single index page listing all discovered recipes.
-} -}
module Roux.Server ( module Roux.Server (
app, app,
@@ -9,6 +10,9 @@ module Roux.Server (
import Control.Monad (when) import Control.Monad (when)
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import Data.Char (toLower)
import Data.List (find)
import Data.Text (Text)
import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai import qualified Network.Wai as Wai
@@ -31,7 +35,6 @@ app recipeDir = do
when (errs > 0) $ when (errs > 0) $
putStrLn $ putStrLn $
"[roux] " <> show errs <> " had parse errors" "[roux] " <> show errs <> " had parse errors"
-- Return the application; recipes are captured in the closure.
pure (handleRequest recipes) pure (handleRequest recipes)
-- | Route an incoming request to the appropriate handler. -- | Route an incoming request to the appropriate handler.
@@ -39,8 +42,30 @@ handleRequest :: [Idx.RecipeInfo] -> Wai.Application
handleRequest recipes request respond = handleRequest recipes request respond =
case Wai.pathInfo request of case Wai.pathInfo request of
[] -> respond (htmlResponse (Html.indexPage recipes)) [] -> respond (htmlResponse (Html.indexPage recipes))
["recipes", rawPath] ->
case lookupRecipe (urlDecodePath rawPath) recipes of
Just info -> respond (htmlResponse (Html.recipePage info))
Nothing -> respond notFound
_ -> respond notFound _ -> respond notFound
-- | Look up a recipe by filename (case-insensitive, .cook extension optional).
lookupRecipe :: FilePath -> [Idx.RecipeInfo] -> Maybe Idx.RecipeInfo
lookupRecipe target recipes =
let normalised = map toLower (stripCookExt target)
in find (\(Idx.RecipeInfo fp _ _) -> map toLower (stripCookExt fp) == normalised) recipes
-- | Strip the .cook extension if present.
stripCookExt :: FilePath -> FilePath
stripCookExt fp
| ".cook" `isSuffixOf` fp = take (length fp - 5) fp
| otherwise = fp
where
isSuffixOf suffix s = suffix == drop (length s - length suffix) s
-- | Decode a percent-encoded URL path segment to a FilePath.
urlDecodePath :: Text -> FilePath
urlDecodePath = Html.urlDecode
-- | Build a 200 HTML response. -- | Build a 200 HTML response.
htmlResponse :: ByteString -> Wai.Response htmlResponse :: ByteString -> Wai.Response
htmlResponse body = htmlResponse body =