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