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