{-# 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 (..), indexPage, recipePage, urlEncode, urlDecode, ) where import Control.Monad (unless) import Data.Aeson (encode) 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 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) -- --------------------------------------------------------------------------- -- 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=Lexend Peta&family=Quicksand:wght@300..700&display=swap');" , "" , ":root { --roux-bg: #F5EFE0; --roux-text: #3D2C1E; --roux-accent: #B85C38; }" , ".lexend-peta { font-family: \"Lexend Peta\", sans-serif; font-weight: 400; 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; }" , "h1, h3 { font-family: \"Lexend Peta\", sans-serif; font-weight: 400; }" , "h1 { font-size: 2rem; font-weight: 500; margin: 0 0 0.5rem; line-height: 1.15; color: var(--roux-text); }" , "h2 { font-family: \"Quicksand\", sans-serif; font-size: 0.7rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--roux-accent); }" , "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-accent); text-decoration: none; }" , "a:hover { text-decoration: underline; }" , "a[role=button] { --pico-color: var(--roux-accent); }" , "" , ".roux-navbar { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; margin-bottom: 1rem; border-bottom: 0.5px solid rgba(184, 92, 56, 0.2); }" , ".roux-navbar ul { display: flex; gap: 1rem; list-style: none; margin: 0; padding: 0; }" , ".roux-navbar li { margin: 0; padding: 0; }" , ".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; }" , "" , "/* Recipe list on index page */" , ".roux-recipes { list-style: none; padding: 0; }" , ".roux-recipes li { padding: 0.5rem 0; border-bottom: 0.5px solid rgba(184, 92, 56, 0.1); }" , ".roux-recipes li:last-child { border-bottom: none; }" , ".roux-recipes a { font-size: 1.1rem; display: block; }" , "" , "/* Recipe page */" , ".roux-meta { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 0.5px solid rgba(184, 92, 56, 0.2); }" , ".roux-meta-item { text-align: center; }" , ".roux-meta-item .label { font-size: 0.65rem; color: var(--roux-accent); 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: 260px 1fr; gap: 2rem; }" , "@media (max-width: 768px) { .roux-grid { grid-template-columns: 1fr; } }" , "" , ".roux-subsection { font-size: 0.65rem; color: var(--roux-accent); margin: 0 0 6px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 500; opacity: 0.75; }" , "" , ".roux-ingredient-row { display: flex; align-items: flex-start; gap: 8px; padding: 4px 0; }" , ".roux-ingredient-row input { margin-top: 5px; appearance: none; -webkit-appearance: none; width: 16px; height: 16px; border: 1.5px solid rgba(184,92,56,0.5); border-radius: 3px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; }" , ".roux-ingredient-row input:checked { background: var(--roux-accent); border-color: var(--roux-accent); }" , ".roux-ingredient-row input:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 12px; font-weight: 700; color: #3D2C1E; 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-accent); margin-right: 4px; }" , ".roux-ingredient-row:has(input:checked) { opacity: 0.45; }" , "" , ".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: 16px; height: 16px; border: 1.5px solid rgba(184,92,56,0.5); border-radius: 3px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; }" , ".roux-step input:checked { background: var(--roux-accent); border-color: var(--roux-accent); }" , ".roux-step input:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 12px; font-weight: 700; color: #3D2C1E; line-height: 1; }" , ".roux-step .step-label { font-size: 0.75rem; color: var(--roux-accent); font-weight: 500; margin: 0 0 2px; }" , ".roux-step .step-text { font-size: 0.9rem; line-height: 1.65; margin: 0; color: var(--roux-text); }" , ".roux-step:has(input:checked) { opacity: 0.45; }" , "" , ".roux-note { display: flex; gap: 12px; padding: 8px 0; }" , ".roux-note .ornament { font-size: 1.4rem; color: var(--roux-accent); line-height: 1; margin-top: -1px; }" , ".roux-note .text { font-size: 0.9rem; line-height: 1.65; margin: 0; color: var(--roux-text); }" , "" , ".roux-course { font-size: 0.65rem; color: var(--roux-accent); 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 0 1.5rem; max-width: 640px; opacity: 0.85; }" , "" , ".roux-ingredient-tag { color: var(--roux-accent); font-weight: 500; }" , ".roux-cookware-tag { font-style: italic; opacity: 0.85; }" , ".roux-timer-tag { color: var(--roux-accent); font-style: italic; }" , ".roux-comment-tag { opacity: 0.6; font-style: italic; }" , ".tag { display: inline-block; background: rgba(184,92,56,0.1); padding: 0.1rem 0.5rem; margin: 0.1rem; border-radius: 4px; font-size: 0.7rem; color: var(--roux-accent); }" ] 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 renderFlatList(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 += renderFlatList(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 += renderFlatList(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(); }" , "" , "})();" ] -- | Render the recipe index page -- shell with embedded JSON + JS rendering. indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString indexPage _mode recipes = page "Roux — Recipes" $ do H.div ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.strong "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;" -- 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 (encode (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 (encode (Prelude.map Idx.toSearchEntry (filterBad recipes))))) -- Inline JS H.script ! A.type_ "text/javascript" $ H.toHtml searchJs where filterOk = filter (isRight . Idx.riRecipe) filterBad = filter (isLeft . Idx.riRecipe) 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.div ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.a ! A.href "/" $ "← Back" 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.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" ! A.style "white-space: nowrap; font-size: 0.8rem; flex-shrink: 0;" $ do "↗ Original recipe" Nothing -> pure () case desc of Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d Nothing -> pure () renderMetaBar meta let sections = NE.toList (recipeSections 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 -- Notes let notes = collectNotes recipe unless (null notes) $ do H.div ! A.style "margin-top: 2rem; padding-top: 1.5rem; border-top: 0.5px solid rgba(184,92,56,0.2);" $ do H.h2 "Notes" mapM_ renderNote 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 sections mapM_ (uncurry renderMethodStep) (zip [1 ..] allSteps) -- | 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. renderNote :: Text -> Html renderNote t = H.div ! A.class_ "roux-note" $ do H.span ! A.class_ "ornament" $ "\182" H.p ! A.class_ "text" $ 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