diff --git a/docs/specs/2026-05-19-schema-org-jsonld-design.md b/docs/specs/2026-05-19-schema-org-jsonld-design.md new file mode 100644 index 0000000..98f2c8d --- /dev/null +++ b/docs/specs/2026-05-19-schema-org-jsonld-design.md @@ -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