feat: add CooklangPrint module for rendering Recipe to .cook text
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user