Add design doc for schema.org JSON-LD recipe types

This commit is contained in:
2026-05-19 22:33:02 -04:00
parent 431386b292
commit 7568f7725e
@@ -0,0 +1,141 @@
# 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