From e1c2a4bdc5c681e3ad2ec36ea3a41d61ce145b79 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Wed, 20 May 2026 13:02:04 -0400 Subject: [PATCH] feat: add CooklangPrint module for rendering Recipe to .cook text --- src/Roux/CooklangPrint.hs | 169 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/Roux/CooklangPrint.hs diff --git a/src/Roux/CooklangPrint.hs b/src/Roux/CooklangPrint.hs new file mode 100644 index 0000000..4912692 --- /dev/null +++ b/src/Roux/CooklangPrint.hs @@ -0,0 +1,169 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- | Render a 'Recipe' to Cooklang (.cook) file text. + +Produces the standard Cooklang file format with YAML front matter, +section headers, and step text with inline markup. +-} +module Roux.CooklangPrint ( + renderRecipe, + filenameFromTitle, +) where + +import Data.Char (isAlphaNum) +import Data.Function ((&)) +import Data.List.NonEmpty (toList) +import Data.Maybe (catMaybes) +import Data.Ratio (denominator, numerator) +import Data.Text (Text) +import qualified Data.Text as T + +import Data.CookLang + +-- | Render a full Recipe to Cooklang file text. +renderRecipe :: Recipe -> Text +renderRecipe recipe = + T.unlines $ catMaybes [renderFrontMatter meta, renderBody recipe] + where + meta = recipeMetadata recipe + +-- | Render YAML front matter for metadata fields that are present. +renderFrontMatter :: Metadata -> Maybe Text +renderFrontMatter meta = + let fields = + catMaybes + [ field "title" (metaTitle meta) + , field "source" (metaSource meta) + , field "description" (metaDescription meta) + , field "author" (metaAuthor meta) + , field "course" (metaCourse meta) + , field "cuisine" (metaCuisine meta) + , field "servings" (renderServings <$> metaServings meta) + , field "totalTime" (renderDurationText <$> metaTotalTime meta) + , field "prepTime" (renderDurationText <$> metaPrepTime meta) + , field "cookTime" (renderDurationText <$> metaCookTime meta) + , case metaTags meta of + [] -> Nothing + ts -> field "tags" (Just (renderTagList ts)) + ] + in case fields of + [] -> Nothing + _ -> Just $ T.unlines $ "---" : fields ++ ["---"] + where + field :: Text -> Maybe Text -> Maybe Text + field _ Nothing = Nothing + field key (Just val) = Just $ key <> ": " <> val + +-- | Render servings like "4 servings". +renderServings :: (Int, Maybe Text) -> Text +renderServings (n, Nothing) = T.pack (show n) +renderServings (n, Just unit) = T.pack (show n) <> " " <> unit + +{- | Render a duration as human-readable text like "30 minutes". +| Render a duration as human-readable text like "30 minutes". +Uses exact fractions for non-integral amounts to avoid floating-point artifacts. +-} +renderDurationText :: Duration -> Text +renderDurationText d = + let amt = durationAmount d + n = numerator amt + d' = denominator amt + amount = if d' == 1 then T.pack (show n) else T.pack (show n <> "/" <> show d') + unit = maybe "" id (durationUnit d) + in T.strip (amount <> " " <> unit) + +-- | Render a tag list as a comma-separated string. +renderTagList :: [Text] -> Text +renderTagList [] = T.empty +renderTagList tags = T.intercalate ", " tags + +-- | Render the recipe body (sections, steps, inline items). +renderBody :: Recipe -> Maybe Text +renderBody recipe = + let sections = toList (recipeSections recipe) + rendered = map renderSection sections + body = T.intercalate "\n" rendered + in if T.null body then Nothing else Just body + +-- | Render a single section. +renderSection :: Section -> Text +renderSection section = + let header = case sectionName section of + Just name -> "== " <> name <> " ==\n" + Nothing -> T.empty + items = toList (sectionBody section) + steps = map renderBodyItem items + in header <> T.intercalate "\n" steps + +-- | Render a single section body item (step, comment, or note). +renderBodyItem :: SectionBodyItem -> Text +renderBodyItem (SecStep step) = renderStep step +renderBodyItem (SecComment t) = "-- " <> t +renderBodyItem (SecNote t) = "> " <> t + +-- | Render a single step (a paragraph of Cooklang text). +renderStep :: Step -> Text +renderStep step = T.concat (map renderStepItem (unStep step)) + +-- | Render a single inline step item back to Cooklang syntax. +renderStepItem :: StepItem -> Text +renderStepItem (StepText t) = t +renderStepItem (StepIngredient ing) = + "@" <> ingName ing <> renderQuantity (ingQuantity ing) <> renderPreparation (ingPreparation ing) +renderStepItem (StepCookware cw) = "#" <> cwName cw <> "{}" +renderStepItem (StepTimer timer) = + case timerName timer of + Just name -> "~" <> name <> "{" <> renderDuration (timerDuration timer) <> "}" + Nothing -> "~{" <> renderDuration (timerDuration timer) <> "}" +renderStepItem (StepRecipeRef ref) = + "@" <> T.pack (refPath ref) <> renderQuantity (refQuantity ref) +renderStepItem (StepEndComment t) = " -- " <> t +renderStepItem (StepComment t) = "[-" <> t <> "-]" +renderStepItem StepBreak = "\\\n" + +-- | Render an optional quantity to Cooklang syntax. +renderQuantity :: Maybe Quantity -> Text +renderQuantity Nothing = T.empty +renderQuantity (Just q) = + let prefix = if quantityFixed q then "=" else "" + amount = renderRational (quantityAmount q) + unit = case quantityUnit q of + Just u -> "%" <> u + Nothing -> T.empty + in "{" <> prefix <> amount <> unit <> "}" + +-- | Render an optional preparation to Cooklang syntax (parenthesized). +renderPreparation :: Maybe Text -> Text +renderPreparation Nothing = T.empty +renderPreparation (Just prep) = "(" <> prep <> ")" + +-- | Render a rational number as a string (e.g. "1/2", "3"). +renderRational :: Rational -> Text +renderRational r = + let n = numerator r + d = denominator r + in if d == 1 + then T.pack (show n) + else T.pack (show n <> "/" <> show d) + +-- | Render a Duration to the Cooklang timer format (e.g. "30%minutes"). +renderDuration :: Duration -> Text +renderDuration d = + let amt = durationAmount d + amount = case denominator amt of + 1 -> T.pack (show (numerator amt)) + _ -> T.pack (show (numerator amt) <> "/" <> show (denominator amt)) + unit = case durationUnit d of + Just u -> "%" <> u + Nothing -> T.empty + in amount <> unit + +-- | Convert a recipe title into a safe filename (e.g. "Fried Rice" → "fried-rice.cook"). +filenameFromTitle :: Text -> FilePath +filenameFromTitle title = + let slug = + title + & T.toLower + & T.map (\c -> if c == ' ' then '-' else c) + & T.filter (\c -> isAlphaNum c || c == '-') + in T.unpack slug <> ".cook"