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 scalarMaybe Text(take first).author: Can be a Person dict ({"@type": "Person", "name": "..."}) or an Organization dict. OnlyPersonis supported; extractname.- Nullable fields: All optional fields use
.:?(orlookup-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.htmlandtest-data/carrot-risotto.html) intoSchemaOrgRecipe. - Convert to
Data.CookLang.Recipeand 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-exportSchemaOrgtypespackage.yaml— no new dependencies needed (aeson, text already present)test/Roux/SchemaOrgSpec.hs— new test module
Out of scope
ToJSONinstances (we'll get these when switching tojson-fleece)- Writing schema.org JSON-LD into recipe pages for SEO
- Full validation against the schema.org spec