.dish File Format Specification
A .dish file is BrightDish’s portable recipe-exchange format. This document describes the JSON schema and the validation/sanitization rules that BrightDish applies on import, so that you can produce your own .dish files.
Versioning
This file documents the latest, current version of the BrightDish file spec. Legacy versions are not documented. BrightDish ensures backwards compatibility with older *.dish files, but you should always adopt the latest version of the spec to ensure that your recipes import as expected.
Rationale
The .dish specification exists because other recipe schemas, including those from competing apps, are poorly structured and fragile. This is particularly true for defining ingredients, where other schemas rely on implied ordering and whitespace. This specification provides consistent, anti-fragile definitions and more capabilities than competing schemas. It is derived from BrightDish's internal data models.
1. File Container
A .dish file is a single JSON document encoded as UTF-8.
| Property | Value |
|---|---|
| Extension | .dish |
| MIME type | application/x-brightdishrecipe |
| UTI | com.incident57.BrightDish.recipeFile |
| Top-level structure | A single JSON object representing one Recipe |
| Compression | Optional gzip (RFC 1952). Uncompressed JSON is also accepted. |
| Decompression cap | 50 MB maximum decompressed size |
| Per file | One recipe per file |
The importer auto-detects gzip via the two-byte magic number 0x1F 0x8B. If gzip-compressed data declares an ISIZE greater than 50 MB the file is rejected before decompression begins.
2. Top-Level Object: Recipe
The root JSON object represents a single recipe. All fields are optional unless marked required. Unknown keys are ignored. The importer is intentionally tolerant — invalid or out-of-range values are clamped or replaced with safe defaults rather than rejecting the file.
2.1 String fields
| Key | Type | Required | Max length | Default if missing | Notes |
|---|---|---|---|---|---|
title | String | No | 500 | "Untitled Recipe" | Trimmed of leading/trailing whitespace. |
summary | String | No | 1000 | "No summary provided." | Trimmed. |
notes | String | No | 10,000 | "" | Trimmed. Freeform user notes. |
author | String | No | 250 | "" | Person/organization who created the recipe. |
source | String | No | 100 | "" | Publication or origin (e.g. "Grandma", "NYT Cooking"). |
yield | String | No | 100 | "" | Freeform (e.g. "24 cookies", "2 loaves"). |
servingSize | String | No | 100 | "" | Freeform (e.g. "1 cup", "2 slices"). |
website | String | No | — | null | URL string. See sanitization below. |
website URL sanitization
The importer normalizes website URLs aggressively:
- Trims whitespace.
- If the string contains no
://, prependshttps://. - Forces scheme to
https. - Strips
query,user, andpasswordcomponents. - Rejects (sets to
null) if the result has no host.
"website": "https://www.example.com/recipe"
2.2 Numeric fields
All out-of-range values are clamped to the allowed range.
| Key | Type | Required | Allowed range | Default | Notes |
|---|---|---|---|---|---|
rating | Double | No | 0.0 … 5.0 | 0.0 | |
difficulty | Int | No | 0 … 10 | 0 | 0 means “unknown”. |
prepMinutes | Int | No | 0 … 999 | 0 | |
cookMinutes | Int | No | 0 … 999 | 0 | |
additionalMinutes | Int | No | 0 … 99,999 | 0 | Resting, marinating, chilling, etc. |
servingsCount | Int | No | 1 … 99 | 1 | |
calorieCount | Int | No | 0 … 9,999 | 0 | Calories per serving. |
2.3 Enum fields
origin (Int)
How the recipe was originally created. Invalid values are ignored.
| Value | Meaning |
|---|---|
0 | Unknown |
1 | AI — Single Cuisine |
2 | AI — Fused Cuisines |
3 | AI — By Ingredients |
4 | AI — Scanned Photos |
5 | AI — Freeform |
6 | Manual entry |
7 | Imported from Paprika |
8 | Imported from a website |
Third-party producers should typically use 8 (Website). Users can search for recipes from a particular origin.
meal (Int)
The temporal meal/event the recipe is associated with. Users can filter for recipes from a particular meal. Invalid values are ignored.
| Value | Meaning |
|---|---|
0 | Breakfast |
1 | Lunch |
2 | Dinner |
3 | Snack |
4 | Dessert |
5 | Side dish |
6 | Appetizer |
7 | Soup |
999 | Undefined / catch-all |
cuisines (Array of String)
An array of cuisine identifiers associated with the recipe. Supports fusion recipes with multiple cuisines. An empty or missing array means no cuisine has been assigned.
Each entry is lowercased on import and then validated against the known cuisine values below. Strings that do not match a known value are silently dropped from the array. Duplicate entries are collapsed into a single entry so each cuisine appears at most once.
Valid values: american, argentinian, australian, brazilian, british, caribbean, chinese, colombian, creole, cuban, ethiopian, filipino, french, german, greek, indian, indonesian, italian, jamaican, japanese, korean, lebanese, mexican, moroccan, nigerian, pakistani, peruvian, polish, portuguese, russian, spanish, thai, turkish, vietnamese, other.
"cuisines": ["italian", "french"]
2.4 Identification
| Key | Type | Required | Notes |
|---|---|---|---|
uuid | String | No | RFC 4122 UUID (any version). If missing, a fresh UUID is assigned during import. Used for duplicate detection — see Section 6. |
2.5 Nested object fields
| Key | Type | Cap | Notes |
|---|---|---|---|
ingredientSections | [IngredientSection] | 10 | Preferred container for ingredients. See §3. |
ingredients | [Ingredient] | 100 | Alternative flat-list form. See §3 for precedence rules. |
utensils | [Utensil] | 99 | See §4. |
steps | [Step] | 99 | See §5. |
nutritionInfo | NutritionInfo | n/a | Single object. See §6. |
images | [RecipeImage] | 20 | See §7. |
tags | [Tag] | 50 | See §8. |
2.6 Producer-only field
| Key | Type | Required | Notes |
|---|---|---|---|
appBuild | Int | No | The BrightDish build that produced this file. Written on export. Ignored on import (informational only). |
3. Ingredients
A recipe organizes ingredients into one or more named sections (e.g. “For the Crust”, “For the Filling”). The format supports two ways of providing ingredients:
- Sectioned — set
ingredientSections. This is what BrightDish itself exports. - Flat — set
ingredientsinstead. The importer wraps the entire flat list in a single auto-created section titled"Other Ingredients".
If a recipe contains only one ingredientSection, BrightDish presents the ingredients as a flat list (it does not display any sectioning UI). Sections will appear only if more than one exists.
3.1 IngredientSection object
| Key | Type | Required | Cap | Default | Notes |
|---|---|---|---|---|---|
title | String | No | 200 chars | "Other Ingredients" | Trimmed. |
sortIndex | Int | No | — | 0 | Ordering hint. Renumbered to 0..N on import. |
ingredients | [Ingredient] | No | 100 items | [] | Anything past 100 is dropped. |
3.2 Ingredient object
| Key | Type | Required | Range | Default | Notes |
|---|---|---|---|---|---|
name | String | No | 150 chars | "[Unnamed Ingredient]" | Trimmed. Should be the noun only — quantity and unit go in their own fields. |
details | String | No | 150 chars | "" | Qualifiers shown beneath the name ("Granny Smith or Honeycrisp", "peeled and diced"). |
quantity | Double | No | 0.0 … 999.9 | 0.0 | Clamped if out of range. |
unit | String | No | 50 chars | "" | Freeform (e.g. "tbsp", "g", "cup"). BrightDish does not interpret the unit semantically. |
sortIndex | Int | No | — | 0 | Renumbered 0..N per section on import. |
3.3 Example
{
"ingredientSections": [
{
"title": "For the Crust",
"sortIndex": 0,
"ingredients": [
{ "name": "All-purpose flour", "quantity": 2.0, "unit": "cups", "sortIndex": 0 },
{ "name": "Butter", "details": "cold, cubed", "quantity": 0.5, "unit": "cup", "sortIndex": 1 }
]
},
{
"title": "For the Filling",
"sortIndex": 1,
"ingredients": [
{ "name": "Apples", "details": "peeled and sliced", "quantity": 6.0, "unit": "", "sortIndex": 0 }
]
}
]
}
4. Utensils
4.1 Utensil object
| Key | Type | Required | Max length | Default | Notes |
|---|---|---|---|---|---|
name | String | No | 150 chars | "[Unnamed Utensil]" | Trimmed. |
sortIndex | Int | No | — | 0 | Renumbered 0..N on import. |
Cap: 99 utensils per recipe.
4.2 Example
"utensils": [
{ "name": "9-inch pie pan", "sortIndex": 0 },
{ "name": "Rolling pin", "sortIndex": 1 }
]
5. Steps
A step is a single cooking instruction OR a section header that visually divides the directions list. A recipe may contain a maximum of 99 steps.
5.1 Step object
| Key | Type | Required | Max length | Default | Notes |
|---|---|---|---|---|---|
number | Int | Yes | — | — | Used as the sort key. The importer renumbers all steps 1..N after sorting; the absolute value is irrelevant, only relative order matters. |
title | String? | No | 250 chars | null | Optional bold heading shown above text. Required when kind is "sectionHeader". |
text | String | No | 2000 chars | "[Empty Step]" | The instruction body. |
kind | String | No | — | "step" | Either "step" or "sectionHeader". See §5.2. |
imageUUID | String? | No | — | null | Optional UUID of an image in the recipe’s images array. See §7. |
5.2 kind values
| Value | Meaning |
|---|---|
"step" | A normal cooking instruction. text is shown. number displays. |
"sectionHeader" | A visual divider grouping nearby steps. Only title is shown. text is ignored. |
5.3 Example
"steps": [
{ "number": 1, "kind": "sectionHeader", "title": "Make the dough" },
{ "number": 2, "text": "In a large bowl, whisk together the flour and salt." },
{ "number": 3, "title": "Cut in the butter", "text": "Work the butter into the flour with your fingertips until it resembles coarse crumbs.", "imageUUID": "9F3D2B7A-8C42-4E11-A6B0-5D8EFC0A1234" }
]
6. NutritionInfo
A single optional object containing per-serving nutritional values. Every field is an optional Double. A null or missing value means “unknown” and is shown dimmed in the UI; an explicit 0 is treated as a real value (zero of that nutrient).
All values are clamped to 0.0 … 9999.0. Units are fixed by field semantics; the value itself carries no unit.
6.1 Field reference
| Group | Key | Unit |
|---|---|---|
| Fats | fat | g |
saturatedFat | g | |
transFat | g | |
unsaturatedFat | g | |
| Core | cholesterol | mg |
sodium | mg | |
| Carbohydrates | carbohydrates | g |
fiber | g | |
sugar | g | |
addedSugar | g | |
| Protein | protein | g |
| Vitamins | vitaminA | mcg |
vitaminB6 | mg | |
vitaminB12 | mcg | |
vitaminC | mg | |
vitaminD | mcg | |
vitaminE | mg | |
vitaminK | mcg | |
biotin | mcg | |
choline | mg | |
folate | mcg | |
niacin | mg | |
pantothenicAcid | mg | |
riboflavin | mg | |
thiamin | mg | |
| Minerals | calcium | mg |
chloride | mg | |
chromium | mcg | |
copper | mg | |
iodine | mcg | |
iron | mg | |
magnesium | mg | |
manganese | mg | |
molybdenum | mcg | |
phosphorus | mg | |
potassium | mg | |
selenium | mcg | |
zinc | mg |
6.2 Example
"nutritionInfo": {
"fat": 12.5,
"saturatedFat": 4.2,
"carbohydrates": 38,
"fiber": 3,
"sugar": 18,
"protein": 4,
"sodium": 220,
"calcium": 40
}
7. Images
7.1 RecipeImage object
| Key | Type | Required | Cap | Default | Notes |
|---|---|---|---|---|---|
uuid | String | No | — | fresh UUID | RFC 4122 UUID. Should be stable per image so Step.imageUUID references resolve. |
sortIndex | Int | No | — | 0 | Renumbered 0..N on import. The image with the lowest sortIndex becomes the recipe’s primary image. |
caption | String | No | 500 chars | "" | Trimmed. |
origin | Int | No | — | 0 | See §7.2. |
imageData | String? | No | — | null | Base64-encoded image bytes. See §7.3. |
7.2 origin values
An integer that describes where an image originated. Invalid values become 0.
| Value | Meaning |
|---|---|
0 | Unknown |
1 | AI-generated |
2 | User-supplied |
3 | Web-imported |
Third-party producers should use 0 or 3.
7.3 Embedded image bytes (imageData)
imageData is a base64-encoded image payload. JPEG is the canonical and recommended format. PNG and other formats supported by Apple’s ImageIO are also accepted on import.
Per-image limits enforced on import:
| Limit | Value |
|---|---|
| Maximum pixel count | 100 megapixels (10,000 × 10,000) |
| Maximum dimension | 3,072 px on the longest side (auto-downsampled) |
| Encoded payload size | Bounded by the 50 MB whole-file decompression cap |
Images exceeding the pixel-count cap are rejected. Images larger than 3,072 px on the longest side are downsampled at decode time.
7.4 Image-Step linking
A Step may attach an image by setting imageUUID to the UUID of a RecipeImage listed in images. The reference must resolve to a uuid that survives the per-recipe cap; otherwise the importer clears it.
7.5 Example
"images": [
{
"uuid": "9F3D2B7A-8C42-4E11-A6B0-5D8EFC0A1234",
"sortIndex": 0,
"caption": "Finished pie, fresh out of the oven",
"origin": 2,
"imageData": "/9j/4AAQSkZJRgABAQEASABIAAD/...<truncated base64>..."
}
]
8. Tags
User-defined freeform labels (e.g. "weeknight", "gluten-free"). A recipe may contain up to 50 tags.
8.1 Tag object
| Key | Type | Required | Max length | Default | Notes |
|---|---|---|---|---|---|
name | String | Yes | 35 chars | — | Trimmed. Empty (post-trim) names are skipped. |
colorHex | String? | No | 9 chars | null | 8-character RGBA hex in Display P3 color space, with optional leading #. Examples: "5A9463FF", "#5A9463FF". Invalid → fallback color. |
8.2 Tag deduplication on import
BrightDish performs fuzzy matching when importing tags:
- Names are normalized by replacing dashes with spaces and lowercasing.
- An incoming
"gluten-free"matches an existing"Gluten Free"and the recipe attaches to the existing tag rather than creating a duplicate. - Likewise, an incoming
"Gluten Free"matches an existing"gluten-free"and the recipe attaches to the existing tag rather than creating a duplicate. - If no match exists, the tag is created fresh.
8.3 Example
"tags": [
{ "name": "weeknight" },
{ "name": "gluten-free", "colorHex": "5A9463FF" }
]
9. Sanitization Summary
Every string field is sanitized by the importer, which:
- Discards leading/trailing whitespace and newlines.
- Truncates to the field’s maximum length (UTF-8 character count).
- Substitutes a default if the result is empty (where a default is documented).
Numeric fields outside their allowed range are clamped to the nearest valid value, never rejected.
Array fields exceeding their cap have the excess dropped (the first N items are kept, sorted by sortIndex/number first where applicable).
The importer is intentionally lenient — almost any malformed-but-decodable input produces a valid (if defaulted) recipe rather than a hard rejection. The exceptions:
- Non-JSON / unparseable file bytes
- Gzip stream declaring more than 50 MB uncompressed
- Decompressed bytes exceeding 50 MB
- Any
Stepmissing the requirednumberfield
These produce a user-facing import-failure error.
10. Duplicate handling
If the importer finds an existing recipe in the user’s library with a matching uuid, it prompts the user to choose:
- Replace existing — the existing recipe (and all its images) is deleted; the imported recipe takes its place.
- Keep both — the imported recipe is assigned a fresh UUID and added alongside the existing one.
- Cancel — the import aborts.
If you want your .dish files to always import as fresh recipes, generate a fresh UUID for uuid each time you produce the file.
11. Minimal Example
The smallest viable .dish file. Everything except steps[].number may be omitted.
{
"title": "Toast",
"summary": "Bread, made warm.",
"ingredients": [
{ "name": "Bread", "quantity": 1, "unit": "slice" }
],
"steps": [
{ "number": 1, "text": "Toast the bread in a toaster until golden brown." }
]
}
12. Full Example
{
"uuid": "8B1C44A2-3F22-49DF-9E3D-1EBD5C7AAB10",
"origin": 6,
"title": "Apple Crumble",
"summary": "A warm autumn dessert with cinnamon-spiced apples under a buttery oat topping.",
"meal": 4,
"cusines": ["american"],
"rating": 4.5,
"difficulty": 3,
"prepMinutes": 20,
"cookMinutes": 40,
"additionalMinutes": 0,
"servingsCount": 6,
"yield": "1 9-inch crumble",
"servingSize": "1 cup",
"calorieCount": 380,
"notes": "Best served warm with vanilla ice cream.",
"author": "John Smith",
"source": "Family Recipe",
"website": "https://example.com/apple-crumble",
"ingredientSections": [
{
"title": "For the Filling",
"sortIndex": 0,
"ingredients": [
{ "name": "Apples", "details": "peeled, cored, sliced", "quantity": 6, "unit": "", "sortIndex": 0 },
{ "name": "Brown sugar", "quantity": 0.5, "unit": "cup", "sortIndex": 1 },
{ "name": "Cinnamon", "quantity": 1, "unit": "tsp", "sortIndex": 2 },
{ "name": "Lemon juice", "quantity": 1, "unit": "tbsp", "sortIndex": 3 }
]
},
{
"title": "For the Topping",
"sortIndex": 1,
"ingredients": [
{ "name": "Rolled oats", "quantity": 1, "unit": "cup", "sortIndex": 0 },
{ "name": "All-purpose flour", "quantity": 0.5, "unit": "cup", "sortIndex": 1 },
{ "name": "Brown sugar", "quantity": 0.5, "unit": "cup", "sortIndex": 2 },
{ "name": "Butter", "details": "cold, cubed", "quantity": 0.5, "unit": "cup", "sortIndex": 3 }
]
}
],
"utensils": [
{ "name": "9-inch baking dish", "sortIndex": 0 },
{ "name": "Mixing bowl", "sortIndex": 1 }
],
"steps": [
{ "number": 1, "text": "Preheat the oven to 350°F (175°C)." },
{ "number": 2, "kind": "sectionHeader", "title": "Make the filling" },
{ "number": 3, "text": "Toss the sliced apples with brown sugar, cinnamon, and lemon juice in a large bowl." },
{ "number": 4, "text": "Transfer the apple mixture to the baking dish." },
{ "number": 5, "kind": "sectionHeader", "title": "Make the topping" },
{ "number": 6, "text": "Combine the oats, flour, and brown sugar. Cut in the cold butter until the mixture resembles coarse crumbs." },
{ "number": 7, "text": "Sprinkle the topping evenly over the apples." },
{ "number": 8, "text": "Bake for 40 minutes, until the topping is golden and the filling is bubbling." }
],
"nutritionInfo": {
"fat": 14,
"saturatedFat": 8,
"carbohydrates": 62,
"fiber": 5,
"sugar": 38,
"protein": 3,
"sodium": 95
},
"images": [
{
"uuid": "9F3D2B7A-8C42-4E11-A6B0-5D8EFC0A1234",
"sortIndex": 0,
"caption": "Fresh out of the oven",
"origin": 2,
"imageData": "/9j/4AAQSkZJRgABAQEASABIAAD/...<truncated>..."
}
],
"tags": [
{ "name": "dessert" },
{ "name": "weeknight", "colorHex": "5A9463FF" }
]
}
13. Producer Checklist
Before publishing .dish files for distribution:
- Use UTF-8 encoding throughout.
- Either gzip-compress the JSON (recommended for files with embedded images) or leave it uncompressed.
- Stay under 50 MB after decompression.
- Provide a fresh
uuidper published file unless you intend duplicate detection to fire. - Provide every
Step.number(the only truly required field). - Encode embedded
imageDataas base64-encoded JPEG; keep each image under 100 megapixels. - Cap your
imagesarray at 20 — anything beyond is silently dropped. - Stay within array caps: 10 ingredient sections, 100 ingredients per section, 99 utensils, 99 steps, 50 tags.
- Set
Step.imageUUIDto theuuidof an entry inimages, never to an arbitrary UUID.