diff --git a/roux-server.cabal b/roux-server.cabal
index d08a592..c7869ea 100644
--- a/roux-server.cabal
+++ b/roux-server.cabal
@@ -16,6 +16,7 @@ build-type: Simple
library
exposed-modules:
+ Data.CookLang
Roux
Roux.Html
Roux.NYTimes
diff --git a/src/Data/CookLang.hs b/src/Data/CookLang.hs
new file mode 100644
index 0000000..886ec83
--- /dev/null
+++ b/src/Data/CookLang.hs
@@ -0,0 +1,239 @@
+{- | Data types for the Cooklang recipe markup language.
+
+Models the structure of a parsed @.cook@ recipe file as specified at
+. 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
+ }
diff --git a/src/Roux.hs b/src/Roux.hs
index 3ce3766..792fd66 100644
--- a/src/Roux.hs
+++ b/src/Roux.hs
@@ -5,4 +5,4 @@ module Roux (
) where
import Roux.Server (app)
-import Roux.Types as X
+import Data.CookLang as X
diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs
index 06db893..f2a6b77 100644
--- a/src/Roux/Html.hs
+++ b/src/Roux/Html.hs
@@ -29,7 +29,7 @@ import Text.Blaze.Html5 as H
import Text.Blaze.Html5.Attributes as A
import qualified Roux.RecipeIndex as Idx
-import Roux.Types
+import Data.CookLang
-- ---------------------------------------------------------------------------
-- Sort mode for the index page
diff --git a/src/Roux/Parser.hs b/src/Roux/Parser.hs
index 4554f90..c8e2f85 100644
--- a/src/Roux/Parser.hs
+++ b/src/Roux/Parser.hs
@@ -18,7 +18,7 @@ import qualified Data.Text as T
import qualified Text.Parsec as P
import Text.Parsec.Text (Parser)
-import Roux.Types
+import Data.CookLang
-- ---------------------------------------------------------------------------
-- Internal raw-block types (parser only)
diff --git a/src/Roux/RecipeIndex.hs b/src/Roux/RecipeIndex.hs
index 78753f4..3ac4904 100644
--- a/src/Roux/RecipeIndex.hs
+++ b/src/Roux/RecipeIndex.hs
@@ -10,7 +10,7 @@ import System.Directory (listDirectory)
import System.FilePath (takeBaseName, takeExtension, (>))
import Roux.Parser (parseCookFile)
-import Roux.Types
+import Data.CookLang
-- | Metadata about a single recipe discovered on disk.
data RecipeInfo = RecipeInfo
diff --git a/src/Roux/Types.hs b/src/Roux/Types.hs
index 0bd1100..e175cfa 100644
--- a/src/Roux/Types.hs
+++ b/src/Roux/Types.hs
@@ -1,236 +1,5 @@
-{- | Core types for Roux. Models the structure of a parsed Cooklang (.cook)
-recipe file as specified at .
-
-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
-@
+{- | Re-exports all types from 'Data.CookLang' for backward compatibility.
-}
-module Roux.Types (
- -- * Recipe
- Recipe (..),
+module Roux.Types (module X) where
- -- * 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
- }
+import Data.CookLang as X
diff --git a/test/Roux/ParserSpec.hs b/test/Roux/ParserSpec.hs
index 26b48bb..9125dd2 100644
--- a/test/Roux/ParserSpec.hs
+++ b/test/Roux/ParserSpec.hs
@@ -7,7 +7,7 @@ import qualified Data.Text.IO as TIO
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe)
import Roux.Parser (parseCookFile)
-import Roux.Types
+import Data.CookLang
spec :: Spec
spec = do