Files
roux/docs/specs/2026-05-19-schema-org-jsonld-design.md

5.8 KiB

Schema.org JSON-LD Recipe Modeling

Date: 2026-05-19

Overview

Model the schema.org Recipe JSON-LD format as Haskell types, with a conversion function to Data.CookLang.Recipe. This supports importing recipes from URLs via external scrapers (see the IMPORT task).

Motivation

The upcoming IMPORT task needs to parse JSON-LD recipe data from arbitrary websites. Schema.org JSON-LD is the standard format used by recipe-scraping tools (e.g. recipe-scrapers). Modeling it explicitly keeps the import pipeline clean: JSON-LD → SchemaOrgRecipe → Cooklang Recipe → .cook file.

Evaluation: NYTRecipe vs schema.org Recipe

The existing NYTRecipe type (from Roux.NYTimes) is NYTimes-specific — it maps to the NYTimes Cooking __NEXT_DATA__ JSON structure. Schema.org Recipe is an open web standard with a different shape:

Aspect NYTRecipe schema.org Recipe
Ingredients Grouped, structured (name, text, quantity) Flat [Text] strings
Steps Grouped with named groups Flat [HowToStep] items
Durations Human-readable ("20 minutes") ISO 8601 ("PT20M")
Metadata Title, URL, totalTime, yield, topnote Also: cuisine, category, keywords, author, image, datePublished, nutrition
Language NYT-specific JSON Open JSON-LD standard

Conclusion: NYTRecipe cannot represent a schema.org Recipe. A new set of types is needed.

Module: Roux.SchemaOrg

A new module with no Roux-specific imports — depends only on Data.CookLang and aeson.

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]
    }

data SchemaOrgPerson = SchemaOrgPerson
    { sopName :: !Text }

data SchemaOrgImageObject = SchemaOrgImageObject
    { soiUrl :: !Text }

data SchemaOrgHowToStep = SchemaOrgHowToStep
    { sohsText :: !Text
    , sohsName :: !(Maybe Text)
    }

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)
    }

JSON parsing

All types derive Generic with custom FromJSON instances using aeson's genericParseJSON where possible, with manual parseJSON for quirks:

  • image: Can be a single URL string ("https://..."), a single ImageObject dict, or an array of ImageObjects. Normalise to [SchemaOrgImageObject].
  • keywords: Can be a single string ("egg, rice") or an array (["egg", "rice"]). Normalise to [Text].
  • recipeInstructions: Can be a single HowToStep dict or an array of them. Normalise to [SchemaOrgHowToStep].
  • recipeCategory / recipeCuisine: Can be a single string or an array. Normalise to scalar Maybe Text (take first).
  • author: Can be a Person dict ({"@type": "Person", "name": "..."}) or an Organization dict. Only Person is supported; extract name.
  • Nullable fields: All optional fields use .:? (or lookup-style) to handle missing/null.

Conversion: schemaOrgToCooklang

schemaOrgToCooklang :: SchemaOrgRecipe -> Either String Recipe
schema.org field Cooklang target
name metaTitle
description metaDescription
url metaSource
totalTime metaTotalTime (parsed from ISO 8601)
prepTime metaPrepTime (parsed from ISO 8601)
cookTime metaCookTime (parsed from ISO 8601)
recipeYield metaServings (parsed same as NYT)
recipeCategory metaCourse
recipeCuisine metaCuisine
keywords metaTags
author.name metaAuthor
image → first contentUrl metaImage
recipeIngredient Section "Ingredients" with one Step containing StepText items
recipeInstructions Section "Method" with one Step per HowToStep

ISO 8601 duration parsing

A helper parseISODuration :: Text -> Maybe Duration converts strings like "PT20M", "PT1H30M", "P1DT2H" to Data.CookLang.Duration.

Conversion: nytToCooklang remains

The existing nytToCooklang in Roux.NYTimes is unchanged. It handles the structured NYT-specific ingredient format which doesn't apply to generic schema.org data.

Testing

  • Parse the schema.org JSON-LD embedded in the existing test data (test-data/fried-rice.html and test-data/carrot-risotto.html) into SchemaOrgRecipe.
  • Convert to Data.CookLang.Recipe and verify key fields.
  • Test ISO 8601 duration parsing with various formats.

Files changed

New

  • src/Roux/SchemaOrg.hs — types, FromJSON instances, conversion function

Modified

  • src/Roux.hs — re-export SchemaOrg types
  • package.yaml — no new dependencies needed (aeson, text already present)
  • test/Roux/SchemaOrgSpec.hs — new test module

Out of scope

  • ToJSON instances (we'll get these when switching to json-fleece)
  • Writing schema.org JSON-LD into recipe pages for SEO
  • Full validation against the schema.org spec