Files
roux/src/Roux/Html.hs
T
jbrechtel 8807ba3851
Build and Deploy / build-and-deploy (push) Successful in 1m30s
feat: show recipe image alongside description when metadata has one
Adds a .roux-desc-row flex container that places the recipe
description text on the left and the image (from metaImage)
on the right, using what was previously empty horizontal space.
Image is capped at 280px, with a subtle shadow and rounded
corners. Responsively stacks on mobile.
2026-05-20 22:35:25 -04:00

813 lines
38 KiB
Haskell

{-# OPTIONS_GHC -Wno-name-shadowing #-}
{- | HTML rendering for the Roux web interface.
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 (
SortMode (..),
ImportError (..),
importPage,
importResultPage,
indexPage,
recipePage,
urlEncode,
urlDecode,
) where
import Control.Monad (unless)
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Function ((&))
import qualified Data.List.NonEmpty as NE
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 Fleece.Aeson as Fleece
import qualified Fleece.Core as FC
import qualified Text.Blaze.Html.Renderer.Utf8 as R
import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A
import Data.CookLang
import qualified Roux.RecipeIndex as Idx
-- ---------------------------------------------------------------------------
-- Sort mode for the index page
-- ---------------------------------------------------------------------------
-- | How to group or sort recipes on the index page.
data SortMode
= AlphaSort
| TagSort
| CourseSort
deriving stock (Eq, Show)
-- | Error type for the import pipeline.
data ImportError = ImportError Text
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Helpers used throughout
-- ---------------------------------------------------------------------------
-- | 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.
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
-}
showQuantity :: Quantity -> Text
showQuantity q =
let amt = quantityAmount q
amount = case denominator amt of
1 -> T.pack (show (numerator amt))
_ -> formatFraction amt
prefix = if quantityFixed q then "=" else ""
unit = maybe "" (\u -> " " <> pluralize amt u) (quantityUnit q)
in prefix <> amount <> unit
-- | Format a rational as a nice fraction string (e.g. @1/2@, @1 1/2@).
formatFraction :: Rational -> Text
formatFraction r =
let w = numerator r `Prelude.div` denominator r
rem = r - toRational w
in case w of
0 -> simpleFrac rem
_ -> T.pack (show w) <> " " <> simpleFrac rem
where
simpleFrac f =
let n = numerator f
d = denominator f
in T.pack (show n <> "/" <> show d)
-- | Pluralize a unit name based on the amount.
pluralize :: Rational -> Text -> Text
pluralize amt unit
| amt == 1 = unit
| otherwise = case T.toLower unit of
-- Units that stay the same (abbreviations, mass, volume)
"g" -> "g"
"kg" -> "kg"
"ml" -> "ml"
"l" -> "l"
"oz" -> "oz"
"lb" -> "lbs"
"tbsp" -> "tbsp"
"tsp" -> "tsp"
"cup" -> "cups"
"tablespoon" -> "tablespoons"
"teaspoon" -> "teaspoons"
"ounce" -> "ounces"
"pound" -> "pounds"
"pinch" -> "pinches"
"clove" -> "cloves"
"item" -> "items"
"piece" -> "pieces"
"can" -> "cans"
"package" -> "packages"
"bag" -> "bags"
"bunch" -> "bunches"
"sprig" -> "sprigs"
"leaf" -> "leaves"
"slice" -> "slices"
"minute" -> "minutes"
"hour" -> "hours"
"second" -> "seconds"
_ -> unit
-- ---------------------------------------------------------------------------
-- Page shell
-- ---------------------------------------------------------------------------
-- | Wrap content in a full HTML5 page with custom styling.
page :: Text -> Html -> ByteString
page title content =
R.renderHtml $
H.docTypeHtml $ do
H.head $ do
H.meta ! A.charset "utf-8"
H.meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1"
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.style $
H.toHtml $
T.unlines
[ "@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600&family=Quicksand:wght@300..700&display=swap');"
, ""
, ":root { --roux-bg: #F5EFE0; --roux-text: #3D2C1E; --roux-accent: #B85C38; --roux-ochre: #BF9428; --roux-sage: #7A9A7A; --roux-paper: #F5EFE0; }"
, ".fraunces { font-family: \"Fraunces\", serif; font-optical-sizing: auto; font-style: normal; }"
, ".quicksand { font-family: \"Quicksand\", sans-serif; font-optical-sizing: auto; font-style: normal; }"
, ""
, "body { background: var(--roux-bg); color: var(--roux-text); font-family: \"Quicksand\", sans-serif; }"
, "main.container { padding-top: 1.5rem; max-width: 1120px; }"
, "h1, h3, .roux-heading { font-family: \"Fraunces\", serif; font-optical-sizing: auto; }"
, "h1 { font-size: 2rem; font-weight: 500; margin: 0 0 0.5rem; line-height: 1.15; color: var(--roux-text); letter-spacing: -0.01em; }"
, "h2, .container h2 { font-family: \"Fraunces\", serif; font-size: 0.75rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--roux-text); opacity: 0.6; }"
, "h3 { font-size: 1rem; font-weight: 500; margin: 1.5rem 0 0.5rem; color: var(--roux-text); text-transform: uppercase; opacity: 0.75; }"
, "a { color: var(--roux-text); text-decoration: none; transition: color 0.15s; }"
, "a:hover { color: var(--roux-accent); }"
, "a[role=button] { --pico-color: var(--roux-accent); }"
, ""
, "/* Navbar */"
, "nav.roux-navbar { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; margin-bottom: 1rem; border-bottom: 1px solid rgba(61, 44, 30, 0.1); }"
, ".roux-navbar .roux-logo { font-family: \"Fraunces\", serif; font-size: 1.1rem; font-weight: 500; color: var(--roux-text); text-decoration: none; }"
, ".roux-navbar .roux-logo:hover { color: var(--roux-text); }"
, ".roux-navbar ul { display: flex; gap: 1rem; list-style: none; margin: 0; padding: 0; }"
, ".roux-navbar li { margin: 0; padding: 0; list-style: none; }"
, ".roux-navbar a { font-size: 0.8rem; letter-spacing: 0.04em; text-transform: uppercase; }"
, ".roux-navbar a.active { color: var(--roux-accent); font-weight: 500; }"
, ""
, "/* Search input */"
, "input[type=search] { background: var(--roux-bg); color: var(--roux-text); border-color: rgba(61, 44, 30, 0.2); }"
, "input[type=search]:focus { border-color: var(--roux-accent); box-shadow: 0 0 0 1px var(--roux-accent); }"
, ""
, "/* Recipe list on index page */"
, ".roux-recipes { list-style: none; padding: 0; }"
, ".roux-recipes li { padding: 0.4rem 0; }"
, ".roux-recipes a { font-size: 1rem; display: block; color: var(--roux-text); }"
, ".roux-recipes a:hover { color: var(--roux-accent); }"
, ".roux-letter-divider { font-family: \"Fraunces\", serif; font-size: 1.1rem; font-weight: 500; color: var(--roux-text); margin: 1.2rem 0 0.25rem; padding-bottom: 0.2rem; border-bottom: 0.5px solid rgba(61, 44, 30, 0.08); }"
, ".roux-recipe-meta { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.1rem; }"
, ".roux-recipe-meta .r-tag { font-size: 0.65rem; color: rgba(61, 44, 30, 0.5); letter-spacing: 0.02em; }"
, ".roux-recipes .r-course { font-size: 0.65rem; color: var(--roux-accent); opacity: 0.6; letter-spacing: 0.03em; text-transform: uppercase; }"
, ""
, "/* Recipe page */"
, ".roux-meta { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(61, 44, 30, 0.08); }"
, ".roux-meta-item { text-align: center; }"
, ".roux-meta-item .label { font-size: 0.65rem; color: rgba(61, 44, 30, 0.5); margin: 0 0 2px; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }"
, ".roux-meta-item .value { font-size: 0.95rem; font-weight: 500; margin: 0; color: var(--roux-text); }"
, ""
, ".roux-grid { display: grid; grid-template-columns: 240px 1fr 180px; gap: 2rem; }"
, "@media (max-width: 1024px) { .roux-grid { grid-template-columns: 240px 1fr; } }"
, "@media (max-width: 768px) { .roux-grid { grid-template-columns: 1fr; } }"
, ""
, ".roux-marginalia { font-size: 0.8rem; line-height: 1.6; color: rgba(61, 44, 30, 0.6); }"
, ".roux-marginalia h2 { font-size: 0.65rem; letter-spacing: 0.03em; text-transform: uppercase; color: rgba(61, 44, 30, 0.4); margin: 0 0 0.75rem; }"
, ".roux-marginalia .note { padding: 0.75rem 0; border-bottom: 0.5px solid rgba(61, 44, 30, 0.06); }"
, ".roux-marginalia .note:last-child { border-bottom: none; }"
, ".roux-marginalia .ornament { color: var(--roux-accent); font-size: 1rem; margin-right: 0.3rem; }"
, "@media (max-width: 1024px) { .roux-marginalia { display: none; } }"
, ""
, ".roux-source-link { font-size: 0.8rem; color: var(--roux-text); opacity: 0.5; white-space: nowrap; flex-shrink: 0; transition: opacity 0.15s; }"
, ".roux-source-link:hover { opacity: 1; color: var(--roux-accent); }"
, ""
, ".roux-subsection { font-size: 0.6rem; color: rgba(61, 44, 30, 0.4); margin: 0 0 6px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 500; }"
, ""
, ".roux-ingredient-row { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; }"
, ".roux-ingredient-row input { margin-top: 5px; appearance: none; -webkit-appearance: none; width: 15px; height: 15px; border: 1.5px solid rgba(61, 44, 30, 0.25); border-radius: 2px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; transform: rotate(0.5deg); transition: border-color 0.15s; }"
, ".roux-ingredient-row input:checked { border-color: rgba(61, 44, 30, 0.5); background: transparent; }"
, ".roux-ingredient-row input:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%) rotate(-0.5deg); font-size: 11px; font-weight: 400; color: var(--roux-text); opacity: 0.5; line-height: 1; }"
, ".roux-ingredient-row .name { font-size: 0.9rem; line-height: 1.5; color: var(--roux-text); transition: opacity 0.15s; }"
, ".roux-ingredient-row .qty { font-size: 0.8rem; color: var(--roux-text); opacity: 0.55; margin-right: 4px; }"
, ".roux-ingredient-row:has(input:checked) .name { opacity: 0.4; }"
, ".roux-ingredient-row .r-plain { font-size: 0.8rem; color: rgba(61, 44, 30, 0.55); font-style: italic; }"
, ""
, ".roux-step { display: flex; align-items: flex-start; gap: 12px; padding: 8px 0; transition: opacity 0.15s; }"
, ".roux-step input { margin-top: 6px; appearance: none; -webkit-appearance: none; width: 15px; height: 15px; border: 1.5px solid rgba(61, 44, 30, 0.25); border-radius: 2px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; transform: rotate(-0.3deg); transition: border-color 0.15s; }"
, ".roux-step input:checked { border-color: rgba(61, 44, 30, 0.5); background: transparent; }"
, ".roux-step input:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%) rotate(0.3deg); font-size: 11px; font-weight: 400; color: var(--roux-text); opacity: 0.5; line-height: 1; }"
, ".roux-step .step-label { font-size: 0.7rem; color: var(--roux-accent); font-weight: 500; margin: 0 0 2px; letter-spacing: 0.01em; }"
, ".roux-step .step-text { font-size: 0.9rem; line-height: 1.65; margin: 0; color: var(--roux-text); }"
, ".roux-step:has(input:checked) .step-text { opacity: 0.4; }"
, ""
, ".roux-course { font-size: 0.6rem; color: var(--roux-accent); opacity: 0.65; margin: 0 0 0.15rem; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; }"
, ".roux-desc { font-size: 0.85rem; line-height: 1.6; color: var(--roux-text); margin: 0; max-width: 640px; opacity: 0.85; }"
, ".roux-desc-row { display: flex; gap: 2rem; align-items: flex-start; margin-bottom: 1.5rem; }"
, ".roux-desc-row .roux-desc { flex: 1; }"
, ".roux-desc-row img { max-width: 280px; height: auto; border-radius: 6px; box-shadow: 0 2px 8px rgba(61, 44, 30, 0.1); }"
, "@media (max-width: 768px) { .roux-desc-row { flex-direction: column; } .roux-desc-row img { max-width: 100%; } }"
, ""
, ".roux-ingredient-tag { border-bottom: 1.5px solid rgba(191, 148, 40, 0.35); }"
, ".roux-cookware-tag, .roux-timer-tag { opacity: 0.85; }"
, ".roux-ingredient-tag .qty { font-size: 0.8rem; color: var(--roux-text); opacity: 0.55; }"
, ".roux-comment-tag { opacity: 0.5; font-style: italic; font-size: 0.85em; }"
, ".tag { display: inline-block; border: 1px solid rgba(122, 154, 122, 0.35); padding: 0.1rem 0.5rem; margin: 0.1rem; border-radius: 4px; font-size: 0.7rem; color: var(--roux-sage); background: transparent; }"
, ".r-section-header { display: block; font-family: \"Fraunces\", serif; font-size: 0.85rem; font-weight: 500; color: var(--roux-text); opacity: 0.5; margin: 1rem 0 0.5rem; letter-spacing: 0.01em; border-top: 0.5px solid rgba(61, 44, 30, 0.06); padding-top: 0.75rem; }"
, ".r-section-header:first-child { border-top: none; margin-top: 0; padding-top: 0; }"
]
H.body $ H.main ! A.class_ "container" $ content
-- ---------------------------------------------------------------------------
-- 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 renderRecipeEntry(r) {"
, " let meta = '';"
, " if (r.tags && r.tags.length > 0) {"
, " meta += '<span class=\\\'r-tag\\\'>' + r.tags.map(escapeHtml).join(' &middot; ') + '</span>';"
, " }"
, " if (r.course) {"
, " meta += '<span class=\\\'r-course\\\'>' + escapeHtml(r.course) + '</span>';"
, " }"
, " let metaHtml = meta ? '<div class=\\\'roux-recipe-meta\\\'>' + meta + '</div>' : '';"
, " return '<li><a href=\\\"/recipes/' + encodeURIComponent(r.filename) + '\\\">' + escapeHtml(r.title) + '</a>' + metaHtml + '</li>';"
, "}"
, ""
, "function renderFlatList(items) {"
, " if (items.length === 0) return '<p>No recipes found.</p>';"
, " let currentLetter = '';"
, " let html = '<ul class=\\\'roux-recipes\\\'>';"
, " items.forEach(r => {"
, " const letter = r.title.charAt(0).toUpperCase();"
, " if (letter !== currentLetter) {"
, " currentLetter = letter;"
, " html += '<li class=\\\'roux-letter-divider\\\'>' + letter + '</li>';"
, " }"
, " html += renderRecipeEntry(r);"
, " });"
, " html += '</ul>';"
, " return html;"
, "}"
, ""
, "function renderFlatListNoAlpha(items) {"
, " if (items.length === 0) return '<p>No recipes found.</p>';"
, " let html = '<ul class=\\\'roux-recipes\\\'>';"
, " items.forEach(r => {"
, " html += renderRecipeEntry(r);"
, " });"
, " 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 += renderFlatListNoAlpha(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 += renderFlatListNoAlpha(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 -- setting hash triggers hashchange"
, "if (!location.hash) location.hash = 'alpha';"
, "else { updateActiveNav(); render(); }"
, ""
, "})();"
]
-- ---------------------------------------------------------------------------
-- SSE live-reload scripts
-- ---------------------------------------------------------------------------
-- | Inline JavaScript for SSE live-reload on the recipe detail page.
sseRecipeJs :: Text
sseRecipeJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "if (!window.EventSource) return;"
, "var el = document.getElementById('roux-current-recipe');"
, "if (!el) return;"
, "var currentRecipe = el.textContent;"
, "var es = new EventSource('/events');"
, "var lastReload = parseInt(sessionStorage.getItem('roux-last-reload') || '0', 10);"
, "es.addEventListener('recipe-changed', function(e) {"
, " if (e.data !== currentRecipe) return;"
, " var now = Date.now();"
, " if (now - lastReload < 3000) return;"
, " sessionStorage.setItem('roux-last-reload', String(now));"
, " location.reload();"
, "});"
, "})();"
]
-- | Inline JavaScript for SSE live-reload on the index page.
sseIndexJs :: Text
sseIndexJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "if (!window.EventSource) return;"
, "var es = new EventSource('/events');"
, "var lastReload = parseInt(sessionStorage.getItem('roux-last-reload') || '0', 10);"
, "es.addEventListener('recipe-changed', function() {"
, " var now = Date.now();"
, " if (now - lastReload < 3000) return;"
, " sessionStorage.setItem('roux-last-reload', String(now));"
, " location.reload();"
, "});"
, "})();"
]
-- | Render the recipe index page -- shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
indexPage _mode recipes =
page "Roux — Recipes" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "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;"
! A.autofocus ""
-- 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 (Fleece.encode (Fleece.encoder (FC.list Idx.recipeSearchEntrySchema)) (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 (Fleece.encode (Fleece.encoder (FC.list Idx.recipeSearchEntrySchema)) (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
-- Inline JS
H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
-- Inline JS for SSE live-reload
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseIndexJs
where
filterOk = filter (isRight . Idx.riRecipe)
filterBad = filter (isLeft . Idx.riRecipe)
-- ---------------------------------------------------------------------------
-- Import page (form + result)
-- ---------------------------------------------------------------------------
-- | Render the import form page, optionally with a validation error.
importPage :: Maybe Text -> ByteString
importPage merror =
page "Roux \8212 Import Recipe" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "\8592 Back"
H.ul $ H.li $ H.strong "Import Recipe"
case merror of
Just err -> H.p ! A.style "color: var(--roux-accent);" $ H.toHtml err
Nothing -> pure ()
H.form ! A.method "POST" ! A.action "/import" $ do
H.label ! A.for "url" $ "Recipe URL"
H.input
! A.type_ "url"
! A.id "url"
! A.name "url"
! A.placeholder "https://cooking.nytimes.com/recipes/..."
! A.required ""
! A.style "width: 100%; margin-bottom: 1rem;"
H.button ! A.type_ "submit" $ "Import Recipe"
-- | Render the import result page with an error message.
importResultPage :: ImportError -> ByteString
importResultPage (ImportError msg) =
page "Roux \8212 Import Error" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "\8592 Back"
H.ul $ H.li $ H.a ! A.href "/import" $ "Try again"
H.h2 "Import failed"
H.p $ H.toHtml msg
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)
hiddenRecipeSpan info
sseScript
Right recipe ->
page (titleText recipe) $ do
renderRecipe recipe
hiddenRecipeSpan info
sseScript
where
hiddenRecipeSpan rInfo =
H.span ! A.id "roux-current-recipe" ! A.style "display: none;" $
H.toHtml (T.pack (Idx.riFilename rInfo))
sseScript =
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs
-- | Render the body of a recipe page (no page shell).
renderRecipe :: Recipe -> Html
renderRecipe recipe = do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
H.ul $ H.li $ H.a ! A.href "/" $ "Recipes"
let meta = recipeMetadata recipe
course = metaCourse meta
desc = metaDescription meta
case course of
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c
Nothing -> pure ()
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do
H.h1 $ H.toHtml (titleText recipe)
case metaSource meta of
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
"↗ Original"
Nothing -> pure ()
H.div ! A.class_ "roux-desc-row" $ do
case desc of
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
Nothing -> pure ()
case metaImage meta of
Just img -> H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
Nothing -> pure ()
renderMetaBar meta
let sections = NE.toList (recipeSections recipe)
notes = collectNotes recipe
H.div ! A.class_ "roux-grid" $ do
-- Ingredients column
H.div $ do
H.h2 "Ingredients"
mapM_ renderIngredientGroup sections
-- Method column
H.div $ do
H.h2 "Method"
renderMethodSections sections
-- Marginalia column
H.div ! A.class_ "roux-marginalia" $ do
case metaSource meta of
Just url -> H.div ! A.class_ "note" $ do
H.h2 "Source"
H.p $ H.toHtml url
Nothing -> pure ()
let recipeTags = metaTags meta
unless (null recipeTags) $ do
H.div ! A.class_ "note" $ do
H.h2 "Tags"
H.p $ H.toHtml (T.intercalate " · " recipeTags)
unless (null notes) $ do
H.h2 "Notes"
mapM_ renderMarginalNote notes
-- | Extract display title from recipe metadata or fallback.
titleText :: Recipe -> Text
titleText recipe = fromMaybe "Untitled Recipe" (metaTitle (recipeMetadata recipe))
-- ---------------------------------------------------------------------------
-- Metadata bar
-- ---------------------------------------------------------------------------
-- | Render the horizontal metadata bar (serves, prep, cook, total).
renderMetaBar :: Metadata -> Html
renderMetaBar meta = do
let items =
catMaybes
[ pairWith (T.pack "Serves") (showServings <$> metaServings meta)
, pairWith (T.pack "Prep") (showDuration <$> metaPrepTime meta)
, pairWith (T.pack "Cook") (showDuration <$> metaCookTime meta)
, pairWith (T.pack "Total") (showDuration <$> metaTotalTime meta)
]
unless (null items) $
H.div ! A.class_ "roux-meta" $
mapM_
( \(label, value) ->
H.div ! A.class_ "roux-meta-item" $ do
H.p ! A.class_ "label" $ H.toHtml label
H.p ! A.class_ "value" $ H.toHtml value
)
items
-- Tags
let tags = metaTags meta
unless (null tags) $
H.p $
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 = fromMaybe "" (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)
-- ---------------------------------------------------------------------------
-- Ingredients column
-- ---------------------------------------------------------------------------
-- | Render ingredients grouped by section.
renderIngredientGroup :: Section -> Html
renderIngredientGroup section = do
let ings = collectSectionIngredients section
unless (null ings) $ do
case sectionName section of
Just n -> H.p ! A.class_ "roux-subsection" $ H.toHtml n
Nothing -> pure ()
H.div $ mapM_ renderIngredientRow ings
-- | Render one ingredient row with checkbox.
renderIngredientRow :: (Text, Maybe Quantity) -> Html
renderIngredientRow (name, qty) =
H.label ! A.class_ "roux-ingredient-row" $ do
H.input ! A.type_ "checkbox"
H.span ! A.class_ "name" $ do
case qty of
Just q -> H.span ! A.class_ "qty" $ H.toHtml (showQuantity q <> " ")
Nothing -> pure ()
H.toHtml name
-- | Collect unique ingredients from a single section.
collectSectionIngredients :: Section -> [(Text, Maybe Quantity)]
collectSectionIngredients section =
let ings =
NE.toList (sectionBody section)
>>= bodyItemSteps
>>= unStep
>>= \case
StepIngredient i -> [i]
_ -> []
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)
-- ---------------------------------------------------------------------------
-- Method column
-- ---------------------------------------------------------------------------
-- | Render the method column: all sections with numbered steps.
renderMethodSections :: [Section] -> Html
renderMethodSections sections = do
let allSteps = concatMap sectionMethodSteps (filter isMethodSection sections)
mapM_ (uncurry renderMethodStep) (zip [1 ..] allSteps)
-- | Is this section a method section (not an ingredient listing)?
isMethodSection :: Section -> Bool
isMethodSection section =
case sectionName section of
Just n -> not (T.toLower n `elem` ["ingredients", "ingredient"])
Nothing -> True
-- | Extract (section name, body items) from a section for the method column.
sectionMethodSteps :: Section -> [(Maybe Text, SectionBodyItem)]
sectionMethodSteps section =
let name = sectionName section
in Prelude.map (name,) (NE.toList (sectionBody section))
-- | Render one step in the method column (with step number and checkbox).
renderMethodStep :: Int -> (Maybe Text, SectionBodyItem) -> Html
renderMethodStep stepNum (_, SecStep step) = do
H.label ! A.class_ "roux-step" $ do
H.input ! A.type_ "checkbox"
H.div $ do
H.p ! A.class_ "step-label" $ "Step " <> H.toHtml (T.pack (show stepNum))
H.p ! A.class_ "step-text" $ mapM_ renderStepItem (unStep step)
renderMethodStep _ (_, SecComment t) =
H.div ! A.class_ "roux-step" $
H.div $ do
H.p ! A.class_ "step-text" $ H.em $ H.toHtml ("-- " <> t)
renderMethodStep _ (_, SecNote t) =
H.div ! A.class_ "roux-step" $
H.div $ do
H.p ! A.class_ "step-text" $ H.small $ H.toHtml ("> " <> t)
-- ---------------------------------------------------------------------------
-- Step items (inline rendering within a step)
-- ---------------------------------------------------------------------------
-- | Render one inline element within a step.
renderStepItem :: StepItem -> Html
renderStepItem (StepText t) = H.toHtml t
renderStepItem (StepIngredient ing) = do
H.span ! A.class_ "roux-ingredient-tag" $ H.toHtml (ingName ing)
case ingQuantity ing of
Nothing -> pure ()
Just q -> " " <> (H.span ! A.class_ "qty" $ H.toHtml (showQuantity q))
renderStepItem (StepCookware cw) =
H.span ! A.class_ "roux-cookware-tag" $
H.toHtml (cwName cw)
renderStepItem (StepTimer timer) =
H.span ! A.class_ "roux-timer-tag" $ do
case timerName timer of
Just n -> H.toHtml n >> " "
Nothing -> pure ()
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-tag" $ H.em $ H.toHtml (" -- " <> t)
renderStepItem (StepComment t) =
H.span ! A.class_ "roux-comment-tag" $ H.em $ H.toHtml ("[- " <> t <> " -]")
renderStepItem StepBreak =
H.br
-- ---------------------------------------------------------------------------
-- Notes section
-- ---------------------------------------------------------------------------
{- | Render a note with a decorative ornament.
| Render a note in the marginalia column (compact, no ornament).
-}
renderMarginalNote :: Text -> Html
renderMarginalNote t =
H.div ! A.class_ "note" $ H.p $ H.toHtml t
-- | Collect all note texts from the recipe.
collectNotes :: Recipe -> [Text]
collectNotes recipe =
recipe
& recipeSections
& NE.toList
>>= NE.toList . sectionBody
>>= \case
SecNote t -> [t]
_ -> []
-- ---------------------------------------------------------------------------
-- Shared helpers for both columns
-- ---------------------------------------------------------------------------
-- | 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.
urlEncode :: FilePath -> Text
urlEncode = T.pack . concatMap encodeChar
where
encodeChar c
| c == ' ' = "%20"
| c == '#' = "%23"
| c == '%' = "%25"
| 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
-- ---------------------------------------------------------------------------
-- | 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