Add implementation plan for schema.org JSON-LD recipe types
This commit is contained in:
@@ -0,0 +1,679 @@
|
||||
# Schema.org JSON-LD Recipe Types Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Model schema.org Recipe JSON-LD as Haskell types with `FromJSON` instances and a conversion function to `Data.CookLang.Recipe`.
|
||||
|
||||
**Architecture:** New `Roux.SchemaOrg` module with three sections: types + FromJSON, ISO 8601 duration parsing, conversion to Cooklang Recipe. The NYT-specific `nytToCooklang` in `Roux.NYTimes` remains unchanged.
|
||||
|
||||
**Tech Stack:** Haskell (aeson, text), existing test data HTML files
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Data types and `FromJSON` instances
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Roux/SchemaOrg.hs`
|
||||
- Test: `test/Roux/SchemaOrgSpec.hs`
|
||||
|
||||
- [ ] **Step 1: Write failing test — verify JSON from test data parses**
|
||||
|
||||
Create the test file first:
|
||||
|
||||
```haskell
|
||||
module Roux.SchemaOrgSpec (spec) where
|
||||
|
||||
import Data.Aeson (Value)
|
||||
import qualified Data.Aeson as A
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeUtf8)
|
||||
import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)
|
||||
|
||||
import Roux.SchemaOrg
|
||||
|
||||
spec :: Spec
|
||||
spec = describe "SchemaOrg" $ do
|
||||
describe "parseSchemaOrgRecipe" $ do
|
||||
it "parses fried-rice.html embedded JSON-LD" $ do
|
||||
html <- readFile "test-data/fried-rice.html"
|
||||
let decoded = decodeUtf8 <$> tryDecode (T.pack html)
|
||||
decoded `shouldSatisfy` isJust
|
||||
let recipe = parseSchemaOrgRecipe (fromJust decoded)
|
||||
recipe `shouldSatisfy` isJust
|
||||
let Just r = recipe
|
||||
soName r `shouldBe` "Fried Rice"
|
||||
soRecipeYield r `shouldBe` Just "4 to 6 servings"
|
||||
soTotalTime r `shouldBe` Just "PT20M"
|
||||
soRecipeCuisine r `shouldBe` Just "asian"
|
||||
length (soRecipeIngredient r) `shouldBe` 14
|
||||
length (soRecipeInstructions r) `shouldBe` 4
|
||||
|
||||
it "parses carrot-risotto.html embedded JSON-LD" $ do
|
||||
html <- readFile "test-data/carrot-risotto.html"
|
||||
let decoded = decodeUtf8 <$> tryDecode (T.pack html)
|
||||
decoded `shouldSatisfy` isJust
|
||||
let recipe = parseSchemaOrgRecipe (fromJust decoded)
|
||||
recipe `shouldSatisfy` isJust
|
||||
let Just r = recipe
|
||||
soName r `shouldBe` "Carrot Risotto"
|
||||
```
|
||||
|
||||
Add helpers at top of the test file:
|
||||
|
||||
```haskell
|
||||
import Data.Maybe (fromJust, isJust)
|
||||
|
||||
-- | Extract the first schema.org Recipe JSON-LD from HTML.
|
||||
tryDecode :: Text -> Maybe A.Value
|
||||
tryDecode html =
|
||||
case T.splitOn openTag html of
|
||||
(_before : rest : _) ->
|
||||
case T.splitOn "</script>" rest of
|
||||
(jsonContent : _) -> A.decodeStrict (TE.encodeUtf8 (T.strip jsonContent))
|
||||
_ -> Nothing
|
||||
_ -> Nothing
|
||||
where
|
||||
openTag = "<script type=\"application/ld+json\">"
|
||||
```
|
||||
|
||||
Add the test module to the test suite. Check `package.yaml` for how existing test modules are registered. The test file `test/Spec.hs` currently imports `Roux.NYTimesSpec` and `Roux.ParserSpec` — add `Roux.SchemaOrgSpec` similarly.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./hs stack test`
|
||||
Expected: FAIL — module `Roux.SchemaOrg` not found, `Roux.SchemaOrgSpec` not found
|
||||
|
||||
- [ ] **Step 3: Register test module in test/Spec.hs**
|
||||
|
||||
Modify `test/Spec.hs` to add:
|
||||
|
||||
```haskell
|
||||
import qualified Roux.SchemaOrgSpec
|
||||
```
|
||||
|
||||
And add to the test group:
|
||||
|
||||
```haskell
|
||||
schemaOrgTests <- testSpec "SchemaOrg" Roux.SchemaOrgSpec.spec
|
||||
```
|
||||
|
||||
And add `schemaOrgTests` to the `testGroup`.
|
||||
|
||||
- [ ] **Step 4: Create `Roux.SchemaOrg` module with data types and `FromJSON` instances**
|
||||
|
||||
Create `src/Roux/SchemaOrg.hs`:
|
||||
|
||||
```haskell
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Roux.SchemaOrg (
|
||||
SchemaOrgRecipe (..),
|
||||
SchemaOrgPerson (..),
|
||||
SchemaOrgImageObject (..),
|
||||
SchemaOrgHowToStep (..),
|
||||
SchemaOrgNutrition (..),
|
||||
parseSchemaOrgRecipe,
|
||||
schemaOrgToCooklang,
|
||||
parseISODuration,
|
||||
) where
|
||||
|
||||
import Data.Aeson (FromJSON (..), Value (..), (.:), (.:?), (.!=))
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import Data.List.NonEmpty (NonEmpty ((:|)))
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
|
||||
import Data.CookLang
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Types
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
data SchemaOrgRecipe = SchemaOrgRecipe
|
||||
{ soName :: !Text
|
||||
, soDescription :: !(Maybe Text)
|
||||
, soUrl :: !(Maybe Text)
|
||||
, soTotalTime :: !(Maybe Text)
|
||||
, soPrepTime :: !(Maybe Text)
|
||||
, soCookTime :: !(Maybe Text)
|
||||
, soRecipeYield :: !(Maybe Text)
|
||||
, soRecipeCuisine :: !(Maybe Text)
|
||||
, soRecipeCategory :: !(Maybe Text)
|
||||
, soKeywords :: ![Text]
|
||||
, soAuthor :: !(Maybe SchemaOrgPerson)
|
||||
, soImage :: ![SchemaOrgImageObject]
|
||||
, soDatePublished :: !(Maybe Text)
|
||||
, soNutrition :: !(Maybe SchemaOrgNutrition)
|
||||
, soRecipeIngredient :: ![Text]
|
||||
, soRecipeInstructions :: ![SchemaOrgHowToStep]
|
||||
}
|
||||
deriving stock (Eq, Show, Generic)
|
||||
|
||||
data SchemaOrgPerson = SchemaOrgPerson
|
||||
{ sopName :: !Text }
|
||||
deriving stock (Eq, Show, Generic)
|
||||
|
||||
data SchemaOrgImageObject = SchemaOrgImageObject
|
||||
{ soiUrl :: !Text }
|
||||
deriving stock (Eq, Show, Generic)
|
||||
|
||||
data SchemaOrgHowToStep = SchemaOrgHowToStep
|
||||
{ sohsText :: !Text
|
||||
, sohsName :: !(Maybe Text)
|
||||
}
|
||||
deriving stock (Eq, Show, Generic)
|
||||
|
||||
data SchemaOrgNutrition = SchemaOrgNutrition
|
||||
{ sonCalories :: !(Maybe Int)
|
||||
, sonFatContent :: !(Maybe Text)
|
||||
, sonCarbohydrateContent :: !(Maybe Text)
|
||||
, sonProteinContent :: !(Maybe Text)
|
||||
, sonSodiumContent :: !(Maybe Text)
|
||||
, sonSugarContent :: !(Maybe Text)
|
||||
, sonFiberContent :: !(Maybe Text)
|
||||
, sonUnsaturatedFatContent :: !(Maybe Text)
|
||||
, sonSaturatedFatContent :: !(Maybe Text)
|
||||
, sonTransFatContent :: !(Maybe Text)
|
||||
, sonCholesterolContent :: !(Maybe Text)
|
||||
}
|
||||
deriving stock (Eq, Show, Generic)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add custom `FromJSON` instances**
|
||||
|
||||
The schema.org JSON has several quirks that need custom parsing. Add these after the types:
|
||||
|
||||
```haskell
|
||||
-- | Parse an optional text-or-array field as a single Text (first if array).
|
||||
parseOptionalTextOrArray :: A.Object -> A.Key -> Maybe Text
|
||||
parseOptionalTextOrArray obj key = case KM.lookup key obj of
|
||||
Just (A.String t) -> Just t
|
||||
Just (A.Array arr)
|
||||
| (A.String t : _) <- foldr (:) [] arr -> Just t
|
||||
_ -> Nothing
|
||||
|
||||
-- | Parse an optional text-or-array field as a list of Text.
|
||||
parseOptionalTextList :: A.Object -> A.Key -> [Text]
|
||||
parseOptionalTextList obj key = case KM.lookup key obj of
|
||||
Just (A.String t) -> map T.strip (T.splitOn "," t)
|
||||
Just (A.Array arr) -> mapMaybe (\case A.String t -> Just t; _ -> Nothing) (foldr (:) [] arr)
|
||||
_ -> []
|
||||
|
||||
-- | Parse image: single URL string, single ImageObject dict, or array of ImageObject dicts.
|
||||
parseImages :: A.Object -> A.Key -> [SchemaOrgImageObject]
|
||||
parseImages obj key = case KM.lookup key obj of
|
||||
Just (A.String url) -> [SchemaOrgImageObject url]
|
||||
Just (A.Object inner) -> [parseImageObject inner]
|
||||
Just (A.Array arr) -> mapMaybe (\case A.Object o -> Just (parseImageObject o); _ -> Nothing) (foldr (:) [] arr)
|
||||
_ -> []
|
||||
|
||||
parseImageObject :: A.Object -> SchemaOrgImageObject
|
||||
parseImageObject obj =
|
||||
let url = case KM.lookup "url" obj of
|
||||
Just (A.String u) -> u
|
||||
-- Some use contentUrl instead of url
|
||||
Just (A.String u) -> u
|
||||
_ -> case KM.lookup "contentUrl" obj of
|
||||
Just (A.String u) -> u
|
||||
_ -> ""
|
||||
in SchemaOrgImageObject url
|
||||
```
|
||||
|
||||
Wait, there's a pattern match issue above. Let me correct:
|
||||
|
||||
```haskell
|
||||
parseImageObject :: A.Object -> SchemaOrgImageObject
|
||||
parseImageObject obj =
|
||||
let url = case KM.lookup "url" obj of
|
||||
Just (A.String u) -> u
|
||||
_ -> case KM.lookup "contentUrl" obj of
|
||||
Just (A.String u) -> u
|
||||
_ -> ""
|
||||
in SchemaOrgImageObject url
|
||||
```
|
||||
|
||||
Now the main `FromJSON` instance for `SchemaOrgRecipe`:
|
||||
|
||||
```haskell
|
||||
instance FromJSON SchemaOrgRecipe where
|
||||
parseJSON = A.withObject "SchemaOrgRecipe" $ \obj ->
|
||||
SchemaOrgRecipe
|
||||
<$> obj .: "name"
|
||||
<*> obj .:? "description"
|
||||
<*> obj .:? "url"
|
||||
<*> obj .:? "totalTime"
|
||||
<*> obj .:? "prepTime"
|
||||
<*> obj .:? "cookTime"
|
||||
<*> obj .:? "recipeYield"
|
||||
<*> pure (parseOptionalTextOrArray obj "recipeCuisine")
|
||||
<*> pure (parseOptionalTextOrArray obj "recipeCategory")
|
||||
<*> pure (parseOptionalTextList obj "keywords")
|
||||
<*> pure (parsePerson obj)
|
||||
<*> pure (parseImages obj "image")
|
||||
<*> obj .:? "datePublished"
|
||||
<*> pure (parseNutrition obj)
|
||||
<*> obj .:? "recipeIngredient" .!= []
|
||||
<*> pure (parseHowToSteps obj)
|
||||
```
|
||||
|
||||
Helpers:
|
||||
|
||||
```haskell
|
||||
parsePerson :: A.Object -> Maybe SchemaOrgPerson
|
||||
parsePerson obj = case KM.lookup "author" obj of
|
||||
Just (A.Object inner) ->
|
||||
case KM.lookup "name" inner of
|
||||
Just (A.String n) -> Just (SchemaOrgPerson n)
|
||||
_ -> Nothing
|
||||
_ -> Nothing
|
||||
|
||||
parseNutrition :: A.Object -> Maybe SchemaOrgNutrition
|
||||
parseNutrition obj = case KM.lookup "nutrition" obj of
|
||||
Just (A.Object inner) -> Just (parseNutritionObject inner)
|
||||
_ -> Nothing
|
||||
|
||||
parseNutritionObject :: A.Object -> SchemaOrgNutrition
|
||||
parseNutritionObject obj =
|
||||
SchemaOrgNutrition
|
||||
{ sonCalories = parseInt "calories" obj
|
||||
, sonFatContent = lookupText "fatContent" obj
|
||||
, sonCarbohydrateContent = lookupText "carbohydrateContent" obj
|
||||
, sonProteinContent = lookupText "proteinContent" obj
|
||||
, sonSodiumContent = lookupText "sodiumContent" obj
|
||||
, sonSugarContent = lookupText "sugarContent" obj
|
||||
, sonFiberContent = lookupText "fiberContent" obj
|
||||
, sonUnsaturatedFatContent = lookupText "unsaturatedFatContent" obj
|
||||
, sonSaturatedFatContent = lookupText "saturatedFatContent" obj
|
||||
, sonTransFatContent = lookupText "transFatContent" obj
|
||||
, sonCholesterolContent = lookupText "cholesterolContent" obj
|
||||
}
|
||||
|
||||
parseHowToSteps :: A.Object -> [SchemaOrgHowToStep]
|
||||
parseHowToSteps obj = case KM.lookup "recipeInstructions" obj of
|
||||
Just (A.Object inner) -> [parseHowToStep inner]
|
||||
Just (A.Array arr) -> mapMaybe (\case A.Object o -> Just (parseHowToStep o); _ -> Nothing) (foldr (:) [] arr)
|
||||
_ -> []
|
||||
|
||||
parseHowToStep :: A.Object -> SchemaOrgHowToStep
|
||||
parseHowToStep obj =
|
||||
SchemaOrgHowToStep
|
||||
{ sohsText = fromMaybe "" (lookupText "text" obj)
|
||||
, sohsName = lookupText "name" obj
|
||||
}
|
||||
|
||||
-- | Parse an int field (number values in JSON-LD).
|
||||
parseInt :: A.Key -> A.Object -> Maybe Int
|
||||
parseInt key obj = case KM.lookup key obj of
|
||||
Just (A.Number n) -> Just (floor n)
|
||||
Just (A.String t) ->
|
||||
case reads (T.unpack t) of
|
||||
[(n, "")] -> Just n
|
||||
_ -> Nothing
|
||||
_ -> Nothing
|
||||
|
||||
-- | Look up a text field.
|
||||
lookupText :: A.Key -> A.Object -> Maybe Text
|
||||
lookupText key obj = case KM.lookup key obj of
|
||||
Just (A.String t) -> Just t
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `parseSchemaOrgRecipe` entry point**
|
||||
|
||||
```haskell
|
||||
-- | Parse a schema.org Recipe from an arbitrary JSON Value.
|
||||
parseSchemaOrgRecipe :: A.Value -> Maybe SchemaOrgRecipe
|
||||
parseSchemaOrgRecipe val = case A.parseMaybe parseJSON val of
|
||||
Just r -> Just r
|
||||
Nothing -> case val of
|
||||
A.Array arr -> case foldr (:) [] arr of
|
||||
(A.Object obj : _) -> parseFromObject obj
|
||||
_ -> Nothing
|
||||
A.Object obj -> parseFromObject obj
|
||||
_ -> Nothing
|
||||
where
|
||||
parseFromObject obj = case KM.lookup "@type" obj of
|
||||
Just (A.String "Recipe") -> A.parseMaybe parseJSON (A.Object obj)
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
Actually, this is getting complex. Let me simplify:
|
||||
|
||||
```haskell
|
||||
-- | Parse a schema.org Recipe from a JSON Value.
|
||||
parseSchemaOrgRecipe :: A.Value -> Maybe SchemaOrgRecipe
|
||||
parseSchemaOrgRecipe val = do
|
||||
obj <- case val of
|
||||
A.Object o -> Just o
|
||||
A.Array arr -> case foldr (:) [] arr of
|
||||
(A.Object o : _) -> Just o
|
||||
_ -> Nothing
|
||||
_ -> Nothing
|
||||
case KM.lookup "@type" obj of
|
||||
Just (A.String "Recipe") -> A.parseMaybe parseJSON (A.Object obj)
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build and test**
|
||||
|
||||
Run: `./hs stack build`
|
||||
Expected: Compiles successfully.
|
||||
|
||||
Run: `./hs stack test`
|
||||
Expected: Tests should pass if the JSON-LD parses correctly. If not, debug the parsing.
|
||||
|
||||
- [ ] **Step 8: Remove unused imports**
|
||||
|
||||
Check for any unused imports flagged by `-Werror` and clean them up.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Roux/SchemaOrg.hs test/Roux/SchemaOrgSpec.hs test/Spec.hs
|
||||
git commit -m "feat: add SchemaOrgRecipe types and FromJSON parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ISO 8601 duration parsing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Roux/SchemaOrg.hs` — add `parseISODuration`
|
||||
|
||||
- [ ] **Step 1: Add `parseISODuration` function**
|
||||
|
||||
Add to `src/Roux/SchemaOrg.hs`:
|
||||
|
||||
```haskell
|
||||
-- | Parse an ISO 8601 duration string into a Cooklang Duration.
|
||||
-- Supports formats like PT20M, PT1H30M, P1DT2H, PT1H, PT30M, P1D.
|
||||
parseISODuration :: Text -> Maybe Duration
|
||||
parseISODuration t
|
||||
| not ("P" `T.isPrefixOf` t) = Nothing
|
||||
| otherwise = do
|
||||
let rest = T.drop 1 t -- drop the P
|
||||
(daysStr, afterDays) = T.break (== 'T') rest
|
||||
days = if T.null daysStr then 0 else parseNumber daysStr
|
||||
timePart = T.drop 1 afterDays -- drop the T
|
||||
(hours, minutes, seconds) = parseTimeComponent timePart
|
||||
totalMinutes = fromIntegral days * 24 * 60 + fromIntegral hours * 60 + fromIntegral minutes
|
||||
if totalMinutes > 0
|
||||
then Just (Duration (toRational (totalMinutes :: Int)) (Just "minutes"))
|
||||
else Nothing
|
||||
where
|
||||
parseNumber :: Text -> Int
|
||||
parseNumber = fromMaybe 0 . fmap (floor :: Double -> Int) . readMaybe . T.unpack
|
||||
|
||||
parseTimeComponent :: Text -> (Int, Int, Int)
|
||||
parseTimeComponent tc =
|
||||
let (hours, rest1) = parseUnit "H" tc
|
||||
(minutes, rest2) = parseUnit "M" rest1
|
||||
(seconds, _) = parseUnit "S" rest2
|
||||
in (hours, minutes, seconds)
|
||||
|
||||
parseUnit :: Text -> Text -> (Int, Text)
|
||||
parseUnit unit tc =
|
||||
let (numStr, after) = T.break (\c -> c == 'H' || c == 'M' || c == 'S') tc
|
||||
in case T.uncons after of
|
||||
Just (c, rest) | T.singleton c == unit ->
|
||||
(fromMaybe 0 (readMaybe (T.unpack numStr)), rest)
|
||||
_ -> (0, tc)
|
||||
```
|
||||
|
||||
Add the import for `readMaybe` at the top if it's not already imported (from `Text.Read` or from `Roux.NYTimes`' local `readMaybe`). Since `readMaybe` is defined locally in `Roux.NYTimes`, define it locally here too:
|
||||
|
||||
```haskell
|
||||
readMaybe :: (Read a) => String -> Maybe a
|
||||
readMaybe s = case reads s of
|
||||
[(x, "")] -> Just x
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add test for duration parsing**
|
||||
|
||||
Add to `test/Roux/SchemaOrgSpec.hs` in the SchemaOrg describe block:
|
||||
|
||||
```haskell
|
||||
describe "parseISODuration" $ do
|
||||
let chk input expected = it (T.unpack input <> " -> " <> show expected) $
|
||||
parseISODuration input `shouldBe` expected
|
||||
|
||||
chk "PT20M" (Just (Duration 20 (Just "minutes")))
|
||||
chk "PT1H" (Just (Duration 60 (Just "minutes")))
|
||||
chk "PT1H30M" (Just (Duration 90 (Just "minutes")))
|
||||
chk "P1D" (Just (Duration 1440 (Just "minutes")))
|
||||
chk "P1DT2H" (Just (Duration 1560 (Just "minutes")))
|
||||
chk "" Nothing
|
||||
chk "20 minutes" Nothing
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and test**
|
||||
|
||||
Run: `./hs stack build && ./hs stack test`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Roux/SchemaOrg.hs test/Roux/SchemaOrgSpec.hs
|
||||
git commit -m "feat: add parseISODuration for ISO 8601 duration parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Conversion function `schemaOrgToCooklang`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Roux/SchemaOrg.hs` — add `schemaOrgToCooklang`
|
||||
|
||||
- [ ] **Step 1: Add test for conversion**
|
||||
|
||||
Add to `test/Roux/SchemaOrgSpec.hs`:
|
||||
|
||||
```haskell
|
||||
describe "schemaOrgToCooklang" $ do
|
||||
it "converts fried-rice schema.org recipe to Cooklang" $ do
|
||||
html <- readFile "test-data/fried-rice.html"
|
||||
let decoded = decodeUtf8 <$> tryDecode (T.pack html)
|
||||
recipe = fromJust decoded >>= parseSchemaOrgRecipe
|
||||
case recipe of
|
||||
Just r -> case schemaOrgToCooklang r of
|
||||
Right cook -> do
|
||||
metaTitle (recipeMetadata cook) `shouldBe` Just "Fried Rice"
|
||||
metaSource (recipeMetadata cook) `shouldBe` Just "https://cooking.nytimes.com/recipes/12177-fried-rice"
|
||||
metaCourse (recipeMetadata cook) `shouldBe` Just "one pot, side dish"
|
||||
metaCuisine (recipeMetadata cook) `shouldBe` Just "asian"
|
||||
metaTags (recipeMetadata cook) `shouldContain` ["egg", "rice", "vegetarian"]
|
||||
metaDescription (recipeMetadata cook) `shouldSatisfy` isJust
|
||||
let sections = recipeSections cook
|
||||
length (NE.toList sections) `shouldBe` 2 -- ingredients + method
|
||||
Left err -> fail (T.unpack err)
|
||||
Nothing -> fail "Failed to parse schema.org recipe"
|
||||
```
|
||||
|
||||
Add imports at top:
|
||||
```haskell
|
||||
import qualified Data.List.NonEmpty as NE
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement `schemaOrgToCooklang`**
|
||||
|
||||
Add to `src/Roux/SchemaOrg.hs`, after the types and helpers:
|
||||
|
||||
```haskell
|
||||
-- | Convert a SchemaOrgRecipe to a Cooklang Recipe.
|
||||
schemaOrgToCooklang :: SchemaOrgRecipe -> Either String Recipe
|
||||
schemaOrgToCooklang r =
|
||||
Right Recipe
|
||||
{ recipeMetadata = buildMetadata r
|
||||
, recipeSections = buildSections r
|
||||
}
|
||||
|
||||
buildMetadata :: SchemaOrgRecipe -> Metadata
|
||||
buildMetadata r =
|
||||
emptyMetadata
|
||||
{ metaTitle = Just (soName r)
|
||||
, metaDescription = soDescription r
|
||||
, metaSource = soUrl r
|
||||
, metaTotalTime = soTotalTime r >>= parseISODuration
|
||||
, metaPrepTime = soPrepTime r >>= parseISODuration
|
||||
, metaCookTime = soCookTime r >>= parseISODuration
|
||||
, metaServings = parseServings =<< soRecipeYield r
|
||||
, metaAuthor = sopName <$> soAuthor r
|
||||
, metaCourse = soRecipeCategory r
|
||||
, metaCuisine = soRecipeCuisine r
|
||||
, metaTags = soKeywords r
|
||||
, metaImage = case soImage r of
|
||||
(img : _) -> Just (soiUrl img)
|
||||
[] -> Nothing
|
||||
}
|
||||
|
||||
buildSections :: SchemaOrgRecipe -> NonEmpty Section
|
||||
buildSections r =
|
||||
let ingSection = buildIngredientSection (soRecipeIngredient r)
|
||||
methodSection = buildMethodSection (soRecipeInstructions r)
|
||||
sections = catMaybes [Just ingSection, methodSection]
|
||||
in case sections of
|
||||
(s : ss) -> s :| ss
|
||||
[] -> Section Nothing (SecStep (Step []) :| []) :| []
|
||||
|
||||
buildIngredientSection :: [Text] -> Section
|
||||
buildIngredientSection ings
|
||||
| null ings = Section Nothing (SecStep (Step []) :| [])
|
||||
| otherwise =
|
||||
let items = map (\t -> StepText (", " <> t)) ings
|
||||
step = Step items
|
||||
in Section (Just "Ingredients") (SecStep step :| [])
|
||||
|
||||
buildMethodSection :: [SchemaOrgHowToStep] -> Maybe Section
|
||||
buildMethodSection [] = Nothing
|
||||
buildMethodSection steps =
|
||||
let stepItems = map (\[s] -> s) (map (\s -> [SecStep (Step [StepText (sohsText s)])]) steps)
|
||||
in Just (Section (Just "Method") (fromList stepItems))
|
||||
|
||||
-- | Parse a servings string like "4 to 6 servings".
|
||||
parseServings :: Text -> Maybe (Int, Maybe Text)
|
||||
parseServings t =
|
||||
case T.words t of
|
||||
(numStr : rest) -> do
|
||||
n <- readMaybe (T.unpack numStr)
|
||||
let unit = case rest of
|
||||
(_ : _) -> Just (T.intercalate " " rest)
|
||||
[] -> Nothing
|
||||
Just (n, unit)
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
Add `catMaybes` to imports if not already imported:
|
||||
|
||||
```haskell
|
||||
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
|
||||
```
|
||||
|
||||
Add `NE.fromList` — make sure `Data.List.NonEmpty` is imported, add `fromList`:
|
||||
|
||||
```haskell
|
||||
import Data.List.NonEmpty (NonEmpty ((:|)), fromList)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and test**
|
||||
|
||||
Run: `./hs stack build`
|
||||
Expected: Compiles successfully.
|
||||
|
||||
Run: `./hs stack test`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 4: Verify `readMaybe` is defined**
|
||||
|
||||
Check that `readMaybe` is defined in SchemaOrg.hs (it's used by `parseISODuration` and `parseServings`). Add the local definition if needed:
|
||||
|
||||
```haskell
|
||||
readMaybe :: (Read a) => String -> Maybe a
|
||||
readMaybe s = case reads s of
|
||||
[(x, "")] -> Just x
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Roux/SchemaOrg.hs test/Roux/SchemaOrgSpec.hs
|
||||
git commit -m "feat: add schemaOrgToCooklang conversion function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire up re-exports in `Roux.hs`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Roux.hs`
|
||||
|
||||
- [ ] **Step 1: Add SchemaOrg to re-exports**
|
||||
|
||||
Current `src/Roux.hs`:
|
||||
|
||||
```haskell
|
||||
module Roux (
|
||||
app,
|
||||
module X,
|
||||
) where
|
||||
|
||||
import Data.CookLang as X
|
||||
import Roux.Server (app)
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```haskell
|
||||
import Roux.SchemaOrg as X
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and test**
|
||||
|
||||
Run: `./hs stack build`
|
||||
Expected: Compiles successfully.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Roux.hs
|
||||
git commit -m "feat: re-export SchemaOrg types from Roux module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fourmolu formatting and final build
|
||||
|
||||
**Files:**
|
||||
- All modified files
|
||||
|
||||
- [ ] **Step 1: Format all Haskell sources**
|
||||
|
||||
```bash
|
||||
cd /work/personal/roux/roux-main
|
||||
./hs fourmolu -i src/Roux/SchemaOrg.hs src/Roux.hs test/Roux/SchemaOrgSpec.hs test/Spec.hs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Full build and test**
|
||||
|
||||
```bash
|
||||
./hs stack build && ./hs stack test
|
||||
```
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Fix ownership if needed**
|
||||
|
||||
```bash
|
||||
find src test -user root -ls 2>/dev/null && sudo chown -R $(id -u):$(id -g) src test || true
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit any formatting changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: format code with fourmolu"
|
||||
```
|
||||
Reference in New Issue
Block a user