22 KiB
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:
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:
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:
import qualified Roux.SchemaOrgSpec
And add to the test group:
schemaOrgTests <- testSpec "SchemaOrg" Roux.SchemaOrgSpec.spec
And add schemaOrgTests to the testGroup.
- Step 4: Create
Roux.SchemaOrgmodule with data types andFromJSONinstances
Create src/Roux/SchemaOrg.hs:
{-# 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
FromJSONinstances
The schema.org JSON has several quirks that need custom parsing. Add these after the types:
-- | 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:
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:
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:
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
parseSchemaOrgRecipeentry point
-- | 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:
-- | 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
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— addparseISODuration -
Step 1: Add
parseISODurationfunction
Add to src/Roux/SchemaOrg.hs:
-- | 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:
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:
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
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— addschemaOrgToCooklang -
Step 1: Add test for conversion
Add to test/Roux/SchemaOrgSpec.hs:
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:
import qualified Data.List.NonEmpty as NE
- Step 2: Implement
schemaOrgToCooklang
Add to src/Roux/SchemaOrg.hs, after the types and helpers:
-- | 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:
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
Add NE.fromList — make sure Data.List.NonEmpty is imported, add fromList:
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
readMaybeis defined
Check that readMaybe is defined in SchemaOrg.hs (it's used by parseISODuration and parseServings). Add the local definition if needed:
readMaybe :: (Read a) => String -> Maybe a
readMaybe s = case reads s of
[(x, "")] -> Just x
_ -> Nothing
- Step 5: Commit
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:
module Roux (
app,
module X,
) where
import Data.CookLang as X
import Roux.Server (app)
Add:
import Roux.SchemaOrg as X
- Step 2: Build and test
Run: ./hs stack build
Expected: Compiles successfully.
- Step 3: Commit
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
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
./hs stack build && ./hs stack test
Expected: All tests pass.
- Step 3: Fix ownership if needed
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
git add -A
git commit -m "chore: format code with fourmolu"