Add design doc for schema.org JSON-LD recipe types
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user