Extract Cooklang types into Data.CookLang module

- Create src/Data/CookLang.hs with all recipe types (Recipe, Section,
  StepItem, Ingredient, Cookware, Timer, RecipeRef, Quantity, Duration,
  Metadata, emptyMetadata)
- Data.CookLang has zero Roux imports — can be extracted to its own
  package
- Roux.Types now just re-exports from Data.CookLang
- Update all Roux.* modules and tests to import Data.CookLang directly
- All 30 tests passing, hlint clean
This commit is contained in:
2026-05-19 15:59:59 -04:00
parent 21a4cea083
commit 4055bf62c1
8 changed files with 248 additions and 239 deletions
+1
View File
@@ -16,6 +16,7 @@ build-type: Simple
library library
exposed-modules: exposed-modules:
Data.CookLang
Roux Roux
Roux.Html Roux.Html
Roux.NYTimes Roux.NYTimes
+239
View File
@@ -0,0 +1,239 @@
{- | Data types for the Cooklang recipe markup language.
Models the structure of a parsed @.cook@ recipe file as specified at
<https://cooklang.org/docs/spec/>. This module has no dependencies on
Roux-specific modules and can be extracted to its own package.
Hierarchy:
@
Recipe
Metadata — YAML front matter (title, tags, servings, …)
NonEmpty Section — at least one section per recipe
Section
Maybe Text — optional section name ("Dough", "Filling")
NonEmpty SectionBodyItem — interleaved steps, comments, and notes
SectionBodyItem
SecStep Step — a cooking step (paragraph)
SecComment Text — full-line @--@ comment
SecNote Text — @>@ note / commentary
Step
[StepItem]
StepItem
StepText — plain text
StepIngredient — \@name{amount%unit}(preparation)
StepCookware — \#name{}
StepTimer — \~name{duration%unit}
StepRecipeRef — \@.\/path\/to\/recipe{amount}
StepEndComment — @-- end of line comment@
StepComment — @[- block comment -]@
StepBreak — \\ at end of line
@
-}
module Data.CookLang (
-- * Recipe
Recipe (..),
-- * Sections and body items
Section (..),
SectionBodyItem (..),
Step (..),
-- * Inline elements
StepItem (..),
Ingredient (..),
Cookware (..),
Timer (..),
RecipeRef (..),
-- * Quantities and durations
Quantity (..),
Duration (..),
-- * Metadata
Metadata (..),
emptyMetadata,
) where
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
-- ---------------------------------------------------------------------------
-- Top-level
-- ---------------------------------------------------------------------------
-- | A fully parsed Cooklang recipe file.
data Recipe = Recipe
{ recipeMetadata :: !Metadata
, recipeSections :: !(NonEmpty Section)
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Sections and body items
-- ---------------------------------------------------------------------------
{- | A named or unnamed section within a recipe. Each section contains
interleaved steps, full-line comments, and notes.
-}
data Section = Section
{ sectionName :: !(Maybe Text)
, sectionBody :: !(NonEmpty SectionBodyItem)
}
deriving stock (Eq, Show)
-- | An item that appears at the top-level of a section (between blank lines).
data SectionBodyItem
= SecStep !Step
| SecComment !Text
| SecNote !Text
deriving stock (Eq, Show)
{- | A single cooking step — one paragraph of text in the Cooklang file,
separated from other steps by a blank line.
-}
newtype Step = Step
{ unStep :: [StepItem]
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Inline elements
-- ---------------------------------------------------------------------------
-- | An element that appears within a step.
data StepItem
= StepText !Text
| StepIngredient !Ingredient
| StepCookware !Cookware
| StepTimer !Timer
| StepRecipeRef !RecipeRef
| StepEndComment !Text
| StepComment !Text
| StepBreak
deriving stock (Eq, Show)
{- | An ingredient reference.
@\@salt\@ground black pepper{}\@potato{2}\@bacon strips{1%kg}@
-}
data Ingredient = Ingredient
{ ingName :: !Text
, ingQuantity :: !(Maybe Quantity)
, ingPreparation :: !(Maybe Text)
}
deriving stock (Eq, Show)
{- | A piece of cookware.
@\#pot\#potato masher{}@
-}
newtype Cookware = Cookware
{ cwName :: Text
}
deriving stock (Eq, Show)
{- | A timer.
@\{25%minutes\}\~eggs{3%minutes}@
-}
data Timer = Timer
{ timerName :: !(Maybe Text)
, timerDuration :: !Duration
}
deriving stock (Eq, Show)
{- | A reference to another recipe, signalled by a path starting with @.\/@.
@\@.\/sauces\/Hollandaise{150%g}@
-}
data RecipeRef = RecipeRef
{ refPath :: !FilePath
, refQuantity :: !(Maybe Quantity)
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Quantities and durations
-- ---------------------------------------------------------------------------
{- | An ingredient quantity with optional unit and scaling lock.
@{2}\{1%kg}\{1\/2%tbsp}\{=1%tsp}@
-}
data Quantity = Quantity
{ quantityAmount :: !Rational
-- ^ e.g. @1\/2@, @3@, @3\/4@
, quantityUnit :: !(Maybe Text)
-- ^ e.g. @\"kg\"@, @\"tbsp\"@
, quantityFixed :: !Bool
-- ^ @{=1%tsp}@ — locked from scaling
}
deriving stock (Eq, Show)
{- | A timer duration — structurally similar to 'Quantity' but semantically
distinct. Timers are not scaled with serving size.
-}
data Duration = Duration
{ durationAmount :: !Rational
-- ^ e.g. @25@, @3@
, durationUnit :: !(Maybe Text)
-- ^ e.g. @\"minutes\"@, @\"hours\"@
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Metadata
-- ---------------------------------------------------------------------------
{- | Metadata parsed from YAML front matter (the @---@ … @---@ block at the
top of a @.cook@ file).
Canonical metadata keys are lifted to dedicated fields; everything else
lives in 'metaCustom'.
-}
data Metadata = Metadata
{ metaTitle :: !(Maybe Text)
, metaServings :: !(Maybe (Int, Maybe Text))
, metaAuthor :: !(Maybe Text)
, metaSource :: !(Maybe Text)
, metaTotalTime :: !(Maybe Duration)
, metaPrepTime :: !(Maybe Duration)
, metaCookTime :: !(Maybe Duration)
, metaDifficulty :: !(Maybe Text)
, metaCuisine :: !(Maybe Text)
, metaCourse :: !(Maybe Text)
, metaDiet :: ![Text]
, metaTags :: ![Text]
, metaImage :: !(Maybe Text)
, metaDescription :: !(Maybe Text)
, metaCustom :: !(Map Text Text)
}
deriving stock (Eq, Show)
-- | Metadata with all fields set to their default / empty values.
emptyMetadata :: Metadata
emptyMetadata =
Metadata
{ metaTitle = Nothing
, metaServings = Nothing
, metaAuthor = Nothing
, metaSource = Nothing
, metaTotalTime = Nothing
, metaPrepTime = Nothing
, metaCookTime = Nothing
, metaDifficulty = Nothing
, metaCuisine = Nothing
, metaCourse = Nothing
, metaDiet = []
, metaTags = []
, metaImage = Nothing
, metaDescription = Nothing
, metaCustom = Map.empty
}
+1 -1
View File
@@ -5,4 +5,4 @@ module Roux (
) where ) where
import Roux.Server (app) import Roux.Server (app)
import Roux.Types as X import Data.CookLang as X
+1 -1
View File
@@ -29,7 +29,7 @@ import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A import Text.Blaze.Html5.Attributes as A
import qualified Roux.RecipeIndex as Idx import qualified Roux.RecipeIndex as Idx
import Roux.Types import Data.CookLang
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Sort mode for the index page -- Sort mode for the index page
+1 -1
View File
@@ -18,7 +18,7 @@ import qualified Data.Text as T
import qualified Text.Parsec as P import qualified Text.Parsec as P
import Text.Parsec.Text (Parser) import Text.Parsec.Text (Parser)
import Roux.Types import Data.CookLang
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Internal raw-block types (parser only) -- Internal raw-block types (parser only)
+1 -1
View File
@@ -10,7 +10,7 @@ import System.Directory (listDirectory)
import System.FilePath (takeBaseName, takeExtension, (</>)) import System.FilePath (takeBaseName, takeExtension, (</>))
import Roux.Parser (parseCookFile) import Roux.Parser (parseCookFile)
import Roux.Types import Data.CookLang
-- | Metadata about a single recipe discovered on disk. -- | Metadata about a single recipe discovered on disk.
data RecipeInfo = RecipeInfo data RecipeInfo = RecipeInfo
+3 -234
View File
@@ -1,236 +1,5 @@
{- | Core types for Roux. Models the structure of a parsed Cooklang (.cook) {- | Re-exports all types from 'Data.CookLang' for backward compatibility.
recipe file as specified at <https://cooklang.org/docs/spec/>.
Hierarchy:
@
Recipe
Metadata — YAML front matter (title, tags, servings, …)
NonEmpty Section — at least one section per recipe
Section
Maybe Text — optional section name ("Dough", "Filling")
NonEmpty SectionBodyItem — interleaved steps, comments, and notes
SectionBodyItem
SecStep Step — a cooking step (paragraph)
SecComment Text — full-line @--@ comment
SecNote Text — @>@ note / commentary
Step
[StepItem]
StepItem
StepText — plain text
StepIngredient — \@name{amount%unit}(preparation)
StepCookware — \#name{}
StepTimer — \~name{duration%unit}
StepRecipeRef — \@.\/path\/to\/recipe{amount}
StepEndComment — @-- end of line comment@
StepComment — @[- block comment -]@
StepBreak — \\ at end of line
@
-} -}
module Roux.Types ( module Roux.Types (module X) where
-- * Recipe
Recipe (..),
-- * Sections and body items import Data.CookLang as X
Section (..),
SectionBodyItem (..),
Step (..),
-- * Inline elements
StepItem (..),
Ingredient (..),
Cookware (..),
Timer (..),
RecipeRef (..),
-- * Quantities and durations
Quantity (..),
Duration (..),
-- * Metadata
Metadata (..),
emptyMetadata,
) where
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
-- ---------------------------------------------------------------------------
-- Top-level
-- ---------------------------------------------------------------------------
-- | A fully parsed Cooklang recipe file.
data Recipe = Recipe
{ recipeMetadata :: !Metadata
, recipeSections :: !(NonEmpty Section)
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Sections and body items
-- ---------------------------------------------------------------------------
{- | A named or unnamed section within a recipe. Each section contains
interleaved steps, full-line comments, and notes.
-}
data Section = Section
{ sectionName :: !(Maybe Text)
, sectionBody :: !(NonEmpty SectionBodyItem)
}
deriving stock (Eq, Show)
-- | An item that appears at the top-level of a section (between blank lines).
data SectionBodyItem
= SecStep !Step
| SecComment !Text
| SecNote !Text
deriving stock (Eq, Show)
{- | A single cooking step — one paragraph of text in the Cooklang file,
separated from other steps by a blank line.
-}
newtype Step = Step
{ unStep :: [StepItem]
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Inline elements
-- ---------------------------------------------------------------------------
-- | An element that appears within a step.
data StepItem
= StepText !Text
| StepIngredient !Ingredient
| StepCookware !Cookware
| StepTimer !Timer
| StepRecipeRef !RecipeRef
| StepEndComment !Text
| StepComment !Text
| StepBreak
deriving stock (Eq, Show)
{- | An ingredient reference.
@\@salt\@ground black pepper{}\@potato{2}\@bacon strips{1%kg}@
-}
data Ingredient = Ingredient
{ ingName :: !Text
, ingQuantity :: !(Maybe Quantity)
, ingPreparation :: !(Maybe Text)
}
deriving stock (Eq, Show)
{- | A piece of cookware.
@\#pot\#potato masher{}@
-}
newtype Cookware = Cookware
{ cwName :: Text
}
deriving stock (Eq, Show)
{- | A timer.
@\{25%minutes\}\~eggs{3%minutes}@
-}
data Timer = Timer
{ timerName :: !(Maybe Text)
, timerDuration :: !Duration
}
deriving stock (Eq, Show)
{- | A reference to another recipe, signalled by a path starting with @.\/@.
@\@.\/sauces\/Hollandaise{150%g}@
-}
data RecipeRef = RecipeRef
{ refPath :: !FilePath
, refQuantity :: !(Maybe Quantity)
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Quantities and durations
-- ---------------------------------------------------------------------------
{- | An ingredient quantity with optional unit and scaling lock.
@{2}\{1%kg}\{1\/2%tbsp}\{=1%tsp}@
-}
data Quantity = Quantity
{ quantityAmount :: !Rational
-- ^ e.g. @1\/2@, @3@, @3\/4@
, quantityUnit :: !(Maybe Text)
-- ^ e.g. @\"kg\"@, @\"tbsp\"@
, quantityFixed :: !Bool
-- ^ @{=1%tsp}@ — locked from scaling
}
deriving stock (Eq, Show)
{- | A timer duration — structurally similar to 'Quantity' but semantically
distinct. Timers are not scaled with serving size.
-}
data Duration = Duration
{ durationAmount :: !Rational
-- ^ e.g. @25@, @3@
, durationUnit :: !(Maybe Text)
-- ^ e.g. @\"minutes\"@, @\"hours\"@
}
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Metadata
-- ---------------------------------------------------------------------------
{- | Metadata parsed from YAML front matter (the @---@ … @---@ block at the
top of a @.cook@ file).
Canonical metadata keys are lifted to dedicated fields; everything else
lives in 'metaCustom'.
-}
data Metadata = Metadata
{ metaTitle :: !(Maybe Text)
, metaServings :: !(Maybe (Int, Maybe Text))
, metaAuthor :: !(Maybe Text)
, metaSource :: !(Maybe Text)
, metaTotalTime :: !(Maybe Duration)
, metaPrepTime :: !(Maybe Duration)
, metaCookTime :: !(Maybe Duration)
, metaDifficulty :: !(Maybe Text)
, metaCuisine :: !(Maybe Text)
, metaCourse :: !(Maybe Text)
, metaDiet :: ![Text]
, metaTags :: ![Text]
, metaImage :: !(Maybe Text)
, metaDescription :: !(Maybe Text)
, metaCustom :: !(Map Text Text)
}
deriving stock (Eq, Show)
-- | Metadata with all fields set to their default / empty values.
emptyMetadata :: Metadata
emptyMetadata =
Metadata
{ metaTitle = Nothing
, metaServings = Nothing
, metaAuthor = Nothing
, metaSource = Nothing
, metaTotalTime = Nothing
, metaPrepTime = Nothing
, metaCookTime = Nothing
, metaDifficulty = Nothing
, metaCuisine = Nothing
, metaCourse = Nothing
, metaDiet = []
, metaTags = []
, metaImage = Nothing
, metaDescription = Nothing
, metaCustom = Map.empty
}
+1 -1
View File
@@ -7,7 +7,7 @@ import qualified Data.Text.IO as TIO
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe) import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe)
import Roux.Parser (parseCookFile) import Roux.Parser (parseCookFile)
import Roux.Types import Data.CookLang
spec :: Spec spec :: Spec
spec = do spec = do