Files
roux/docs/plans/2026-05-19-schema-org-jsonld.md
T

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.SchemaOrg module with data types and FromJSON instances

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 FromJSON instances

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 parseSchemaOrgRecipe entry 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 — add parseISODuration

  • Step 1: Add parseISODuration function

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 — add schemaOrgToCooklang

  • 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 readMaybe is 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"