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
+17 -5
View File
@@ -128,6 +128,10 @@ pLineBreak = do
_ <- P.newline
return StepBreak
-- | Characters allowed in recipe reference paths.
pPathChar :: Parser Char
pPathChar = P.alphaNum P.<|> P.oneOf "./-_"
-- | Recipe reference: @./path/to/recipe{quantity}
pRecipeRef :: Parser StepItem
pRecipeRef = do
@@ -135,7 +139,7 @@ pRecipeRef = do
-- Consume @ and store ./ as part of the path
_ <- P.char '.'
_ <- P.char '/'
path <- P.many (P.noneOf "{(\n\\")
path <- P.many pPathChar
mqty <- P.optionMaybe (P.between (P.char '{') (P.char '}') pQuantityBody)
return (StepRecipeRef (RecipeRef ("./" <> path) (join mqty)))
@@ -147,6 +151,14 @@ pIngredient = do
prep <- P.optionMaybe (P.try pPreparation)
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.
Used by ingredients. First tries a multi-word name with braces
@@ -156,14 +168,14 @@ pNameWithQuantity :: Parser ([Char], Maybe Quantity)
pNameWithQuantity =
P.try
( do
name <- P.many (P.noneOf "{(\n\\")
name <- P.many pMultiNameChar
_ <- P.char '{'
qty <- pQuantityBody
_ <- P.char '}'
return (name, qty)
)
P.<|> do
name <- P.many1 (P.alphaNum P.<|> P.oneOf ".-'")
name <- P.many1 pSingleNameChar
return (name, Nothing)
{- | Parse a name optionally followed by empty @{}@ braces.
@@ -174,12 +186,12 @@ pNameWithBraces :: Parser [Char]
pNameWithBraces =
P.try
( do
name <- P.many (P.noneOf "{(\n\\")
name <- P.many pMultiNameChar
_ <- P.char '{'
_ <- P.char '}'
return name
)
P.<|> P.many1 (P.alphaNum P.<|> P.oneOf ".-'")
P.<|> P.many1 pSingleNameChar
-- | Parse preparation text inside parentheses: @name(diced)
pPreparation :: Parser Text