# Schema.org JSON-LD Recipe Modeling Date: 2026-05-19 ## Overview Model the [schema.org Recipe](https://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 ```haskell 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` ```haskell 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