diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index 62d2229..743dc27 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -932,14 +932,20 @@ recipePageStyles = , ".rp-section-label { font-size: 12px; letter-spacing: 0.12em; color: #8A7560; text-transform: uppercase; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: baseline; }" , ".rp-serves { font-family: \"Fraunces\", serif; font-size: 28px; margin-bottom: 22px; }" , "" - , ".rp-ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; line-height: 1.9; }" - , ".rp-ingredients li { display: flex; gap: 14px; align-items: baseline; padding: 4px 0; border-bottom: 0.5px dashed rgba(61,40,23,0.15); }" - , ".rp-ingredients li:last-child { border-bottom: none; }" - , ".rp-ingredients .rp-qty { color: #8A7560; min-width: 48px; font-variant-numeric: tabular-nums; }" + , ".rp-ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; }" + , ".rp-ingredient-row { display: flex; align-items: center; gap: 14px; padding: 6px 0; border-bottom: 0.5px dashed rgba(61,40,23,0.15); cursor: pointer; }" + , ".rp-ingredient-row:last-child { border-bottom: none; }" + , ".rp-ingredient-row input[type=checkbox] { 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; transition: border-color 0.15s; margin: 0; }" + , ".rp-ingredient-row input[type=checkbox]:checked { border-color: rgba(61,44,30,0.5); }" + , ".rp-ingredient-row input[type=checkbox]:checked::after { content: '\x2713'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 11px; font-weight: 400; color: var(--roux-text); opacity: 0.5; line-height: 1; }" + , ".rp-ingredient-row .rp-qty { color: #8A7560; min-width: 48px; font-variant-numeric: tabular-nums; transition: opacity 0.15s; }" + , ".rp-ingredient-row .name { font-size: 0.9rem; line-height: 1.5; color: var(--roux-text); transition: opacity 0.15s; }" + , ".rp-ingredient-row:has(input:checked) .rp-qty { opacity: 0.4; }" + , ".rp-ingredient-row:has(input:checked) .name { opacity: 0.4; text-decoration: line-through; }" , ".rp-ing-section { font-size: 13px; font-weight: 500; color: #6B5847; margin: 16px 0 6px; letter-spacing: 0.03em; }" , "" - , ".rp-method { font-size: 15px; line-height: 1.7; color: #4A3525; }" - , ".rp-method p { margin: 0 0 14px; }" + , ".rp-method { font-size: 15px; line-height: 1.7; color: var(--roux-text); }" + , ".rp-method p { margin: 0 0 14px; color: var(--roux-text); }" , ".rp-method .rp-step { font-weight: 500; color: var(--roux-text); margin-right: 4px; }" , ".rp-method .rp-comment { color: #8A7560; font-size: 14px; }" , ".rp-method .rp-note { color: #8A7560; font-size: 13px; }" @@ -1068,30 +1074,36 @@ formatFullDate = T.pack . Time.formatTime Time.defaultTimeLocale "%B %e, %Y" -- | Render ingredients as a flat list grouped by section (mockup style). rpIngredientList :: [Section] -> Html rpIngredientList sections = do - let ingSects = filter isIngSection sections - unnamed = [s | s <- ingSects, isUnnamedIngSection s] - named = [s | s <- ingSects, not (isUnnamedIngSection s)] + -- Collect ingredients from all sections, deduplicated per section + let withIngredients = filter (not . Prelude.null . collectIngredients) sections + unnamed = [s | s <- withIngredients, isUnnamedIngredientSection s] + named = [s | s <- withIngredients, not (isUnnamedIngredientSection s)] -- Unnamed sections first, then named unless (null unnamed) $ - H.ul ! A.class_ "rp-ingredients" $ - mapM_ rpIngredientItems (concatMap collectIngredients unnamed) + H.div ! A.class_ "rp-ingredients" $ + mapM_ rpIngredientItems (dedupIngredients (concatMap collectIngredients unnamed)) mapM_ rpNamedIngredientSection named where - isIngSection s = - case sectionName s of - Just n -> let lower = T.toLower n in lower `elem` ["ingredients", "ingredient"] - Nothing -> not . Prelude.null $ collectIngredients s - isUnnamedIngSection s = Prelude.null (sectionName s) + isUnnamedIngredientSection s = Prelude.null (sectionName s) + +-- | Deduplicate ingredients by name, keeping the first occurrence. +dedupIngredients :: [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)] +dedupIngredients = go [] + where + go seen [] = reverse seen + go seen ((name, qty) : rest) + | name `elem` Prelude.map fst seen = go seen rest + | otherwise = go ((name, qty) : seen) rest -- | Render a named ingredient section with a sub-heading. rpNamedIngredientSection :: Section -> Html rpNamedIngredientSection section = do - let ings = collectIngredients section + let ings = dedupIngredients (collectIngredients section) unless (null ings) $ do case sectionName section of Just n -> H.p ! A.class_ "rp-ing-section" $ H.toHtml n Nothing -> pure () - H.ul ! A.class_ "rp-ingredients" $ mapM_ rpIngredientItems ings + H.div ! A.class_ "rp-ingredients" $ mapM_ rpIngredientItems ings -- | Collect ingredient (name, quantity) pairs from a section. collectIngredients :: Section -> [(Text, Maybe Quantity)] @@ -1106,24 +1118,31 @@ collectIngredients section = -- | Render one ingredient item in the mockup-style list. rpIngredientItems :: (Text, Maybe Quantity) -> Html rpIngredientItems (name, mQty) = - H.li $ do + H.label ! A.class_ "rp-ingredient-row" $ do + H.input ! A.type_ "checkbox" H.span ! A.class_ "rp-qty" $ case mQty of Just q -> H.toHtml (showQuantity q) Nothing -> H.toHtml ("\x2014" :: Text) - H.span $ H.toHtml name + H.span ! A.class_ "name" $ H.toHtml name -- | Render numbered method steps as plain paragraphs (mockup style). rpMethodList :: [Section] -> Html rpMethodList sections = do let methodSects = filter (not . isIngSection) sections - allSteps = concatMap extractSteps methodSects - meaningful = filter (not . isEmptyStep) allSteps - H.div ! A.class_ "rp-method" $ mapM_ (uncurry rpRenderMethodStep) (zip [1 ..] meaningful) + allItems = concatMap extractSteps methodSects + (_, html) = Prelude.foldl go (1, mempty) allItems + H.div ! A.class_ "rp-method" $ html where extractSteps s = Prelude.map (sectionName s,) (NE.toList (sectionBody s)) - isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step) - isEmptyStep _ = False + -- \| Fold through items, incrementing step counter only for non-empty steps. + -- Comments and notes render without consuming a step number, so any + -- horizontal-rule separators or front-matter comments don't throw off + -- the numbering. + go (n, acc) (sname, SecStep step) + | null (unStep step) || all isBlankText (unStep step) = (n, acc) + | otherwise = (n + 1, acc <> rpRenderMethodStep n (sname, SecStep step)) + go (n, acc) item = (n, acc <> rpRenderMethodStep 0 item) isBlankText (StepText t) = T.all (== ' ') t isBlankText _ = False isIngSection s =