feat: add CooklangPrint module for rendering Recipe to .cook text

This commit is contained in:
2026-05-20 13:02:04 -04:00
parent 751a9f853c
commit e1c2a4bdc5
+169
View File
@@ -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"