{-# 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 += '

Unparseable recipes

';" , " }" , " listEl.innerHTML = html;" , "}" , "" , "function renderRecipeEntry(r) {" , " let meta = '';" , " if (r.tags && r.tags.length > 0) {" , " meta += '' + r.tags.map(escapeHtml).join(' · ') + '';" , " }" , " if (r.course) {" , " meta += '' + escapeHtml(r.course) + '';" , " }" , " let metaHtml = meta ? '
' + meta + '
' : '';" , " return '
  • ' + escapeHtml(r.title) + '' + metaHtml + '
  • ';" , "}" , "" , "function renderFlatList(items) {" , " if (items.length === 0) return '

    No recipes found.

    ';" , " let currentLetter = '';" , " let html = '';" , " return html;" , "}" , "" , "function renderFlatListNoAlpha(items) {" , " if (items.length === 0) return '

    No recipes found.

    ';" , " let html = '';" , " 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 '

    No tagged recipes.

    ';" , " let html = '';" , " tags.forEach(t => {" , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));" , " html += '

    ' + escapeHtml(t) + '

    ';" , " 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 '

    No recipes with course metadata.

    ';" , " let html = '';" , " courses.forEach(c => {" , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));" , " html += '

    ' + escapeHtml(c) + '

    ';" , " 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