Created
July 6, 2026 12:21
-
-
Save au5ton/55cd284e51cee3307cc2aff537211cf9 to your computer and use it in GitHub Desktop.
Crouton Recipe (.crumb) JSON schema
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "$schema": "http://json-schema.org/draft-07/schema#", | |
| "$id": "https://crouton.app/crumb.schema.json", | |
| "title": "Crouton Recipe (.crumb)", | |
| "description": "JSON schema for the .crumb file format used by the Crouton iOS recipe app (https://crouton.app). A .crumb file is a single JSON object representing one recipe. Files are UTF-8 encoded plain JSON \u2014 not zipped or otherwise wrapped. All UUID fields use uppercase RFC 4122 format (e.g. 'B8DE6DC2-0A3F-42E3-9213-98FBC0E333CC'). Fields that are absent from a recipe object are simply omitted rather than null; a minimal valid recipe contains only the required fields.", | |
| "type": "object", | |
| "required": [ | |
| "uuid", | |
| "name", | |
| "defaultScale", | |
| "isPublicRecipe", | |
| "ingredients", | |
| "steps", | |
| "images", | |
| "tags", | |
| "folderIDs" | |
| ], | |
| "additionalProperties": false, | |
| "properties": { | |
| "uuid": { | |
| "type": "string", | |
| "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", | |
| "description": "The globally unique identifier for this recipe, in uppercase RFC 4122 UUID format. Generated once when the recipe is created and never changes. Every recipe object \u2014 and every ingredient, step, etc. \u2014 carries its own UUID." | |
| }, | |
| "name": { | |
| "type": "string", | |
| "minLength": 1, | |
| "description": "The display title of the recipe as shown at the top of the recipe screen. Plain text, no markup." | |
| }, | |
| "defaultScale": { | |
| "type": "integer", | |
| "minimum": 1, | |
| "description": "The default serving multiplier applied when the recipe is opened. A value of 1 means the recipe is shown at its base yield (i.e. as authored). A value of 2 means all ingredient amounts are doubled by default. The base yield is defined by the 'serves' field. Observed values in the wild: 1 and 2." | |
| }, | |
| "isPublicRecipe": { | |
| "type": "boolean", | |
| "description": "Whether this recipe has been published publicly to the Crouton community. Set to false for personal/private recipes (the default for newly created recipes). When true the recipe is discoverable by other Crouton users." | |
| }, | |
| "serves": { | |
| "type": "integer", | |
| "minimum": 1, | |
| "description": "The number of servings this recipe yields at its base scale (defaultScale=1). Optional \u2014 omitted when the user has not specified a serving size. Used together with defaultScale to compute the displayed yield." | |
| }, | |
| "duration": { | |
| "type": "integer", | |
| "minimum": 0, | |
| "description": "Preparation time in minutes (i.e. hands-on time before cooking begins \u2014 chopping, measuring, marinating, etc.). Optional \u2014 omitted when the user has not specified a prep time. Displayed separately from cookingDuration in the app UI." | |
| }, | |
| "cookingDuration": { | |
| "type": "integer", | |
| "minimum": 0, | |
| "description": "Active cooking time in minutes (i.e. time on the stove, in the oven, on the grill, etc.). Optional \u2014 omitted when the user has not specified a cook time. Displayed separately from duration (prep time) in the app UI." | |
| }, | |
| "sourceName": { | |
| "type": "string", | |
| "description": "The human-readable name of the website or publication the recipe was sourced from (e.g. 'budgetbytes.com'). Optional \u2014 omitted for recipes created from scratch. Displayed as attribution beneath the recipe title. Should not include the protocol prefix (https://)." | |
| }, | |
| "webLink": { | |
| "type": "string", | |
| "format": "uri", | |
| "description": "The full URL of the original recipe page that this recipe was imported or adapted from (e.g. 'https://www.budgetbytes.com/skillet-cheeseburger-pasta/'). Optional \u2014 omitted for recipes created from scratch. Displayed as a tappable link alongside sourceName." | |
| }, | |
| "neutritionalInfo": { | |
| "type": "string", | |
| "description": "Free-form nutritional information as a plain text string. Optional \u2014 omitted when the user has not entered nutritional data. Note the intentional misspelling ('neutritional' not 'nutritional') \u2014 this is the literal key name used in the app. The observed format is a comma-newline-separated list of 'Label: value unit' pairs (e.g. 'Calories: 555 kcal,\\nProtein: 27 g,\\nFat: 26 g'), but the schema does not enforce any particular structure." | |
| }, | |
| "sourceImage": { | |
| "type": "string", | |
| "description": "A base64-encoded JPEG thumbnail of the recipe's primary photo, stored without a data URI prefix (i.e. the raw base64 string, not 'data:image/jpeg;base64,...'). Always a 32\u00d732 pixel RGB JPEG. Optional \u2014 omitted when the recipe has no photos. This is a low-resolution preview used for fast list rendering; the full-resolution images are stored in the 'images' array. The thumbnail is derived from images[0]." | |
| }, | |
| "images": { | |
| "type": "array", | |
| "description": "Ordered list of full-resolution recipe photos. Each element is a base64-encoded JPEG string (no data URI prefix). The first image (index 0) is the primary/cover photo and is the source for the 'sourceImage' thumbnail. Additional images are supplementary step or plating photos. An empty array means the recipe has no photos.", | |
| "items": { | |
| "type": "string", | |
| "description": "A base64-encoded JPEG image string (no 'data:image/jpeg;base64,' prefix). Observed primary photo resolutions include 1200\u00d7900 and 650\u00d7488 pixels." | |
| } | |
| }, | |
| "tags": { | |
| "type": "array", | |
| "description": "User-defined tags for categorising and filtering recipes (e.g. 'weeknight', 'vegetarian', 'quick'). An empty array means no tags have been assigned. The exact element type for tag entries is not confirmed from the available samples \u2014 the array was empty in all examined files.", | |
| "items": {} | |
| }, | |
| "folderIDs": { | |
| "type": "array", | |
| "description": "List of UUIDs of the Crouton folders (collections) that this recipe belongs to. A recipe can belong to multiple folders simultaneously. An empty array means the recipe is not in any folder. Each element is an uppercase RFC 4122 UUID string matching the UUID of a folder object in the app.", | |
| "items": { | |
| "type": "string", | |
| "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", | |
| "description": "Uppercase RFC 4122 UUID of a Crouton folder." | |
| } | |
| }, | |
| "ingredients": { | |
| "type": "array", | |
| "description": "Ordered list of ingredient entries for this recipe. Each entry is either a measurable ingredient or a section header (a visual divider that groups ingredients under a label, e.g. 'For the sauce:'). Entries are displayed in ascending 'order' value. The array may be empty for incomplete/draft recipes.", | |
| "items": { | |
| "$ref": "#/definitions/ingredientEntry" | |
| } | |
| }, | |
| "steps": { | |
| "type": "array", | |
| "description": "Ordered list of instruction entries for this recipe. Each entry is either a cooking step (a paragraph of instructions) or a section header (a bold label that groups steps, e.g. 'To serve:'). Entries are displayed in ascending 'order' value. The array may be empty for incomplete/draft recipes.", | |
| "items": { | |
| "$ref": "#/definitions/stepEntry" | |
| } | |
| } | |
| }, | |
| "definitions": { | |
| "uuid": { | |
| "type": "string", | |
| "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", | |
| "description": "Uppercase RFC 4122 UUID string." | |
| }, | |
| "ingredientEntry": { | |
| "type": "object", | |
| "description": "A single row in the ingredients list. May be a measurable ingredient or a section header. The 'quantity.quantityType' field determines which: when it is 'SECTION', this entry is a section header and 'quantity.amount' is absent; for all other quantityType values, 'quantity.amount' is present and this entry is a normal ingredient.", | |
| "required": [ | |
| "uuid", | |
| "order", | |
| "ingredient", | |
| "quantity" | |
| ], | |
| "additionalProperties": false, | |
| "properties": { | |
| "uuid": { | |
| "$ref": "#/definitions/uuid", | |
| "description": "Unique identifier for this ingredient list entry (the row itself, not the ingredient). Distinct from ingredient.uuid." | |
| }, | |
| "order": { | |
| "type": "integer", | |
| "minimum": 0, | |
| "description": "Zero-based display position of this entry within the ingredients list. Entries are rendered in ascending order. Values are contiguous starting from 0." | |
| }, | |
| "ingredient": { | |
| "$ref": "#/definitions/ingredientMaster", | |
| "description": "The ingredient identity (name and its own UUID). When quantityType is 'SECTION', the ingredient.name holds the section header label text (e.g. 'For the sauce:')." | |
| }, | |
| "quantity": { | |
| "$ref": "#/definitions/quantity", | |
| "description": "The measurement for this ingredient. When quantityType is 'SECTION', only 'quantityType' is present and 'amount' is omitted." | |
| } | |
| } | |
| }, | |
| "ingredientMaster": { | |
| "type": "object", | |
| "description": "The canonical identity of an ingredient. Each distinct ingredient in the user's ingredient library has its own UUID. The same ingredient UUID may appear across multiple recipes if the user reuses it from their library.", | |
| "required": [ | |
| "uuid", | |
| "name" | |
| ], | |
| "additionalProperties": false, | |
| "properties": { | |
| "uuid": { | |
| "$ref": "#/definitions/uuid", | |
| "description": "Unique identifier for this ingredient in the user's ingredient library. Stable across recipe edits." | |
| }, | |
| "name": { | |
| "type": "string", | |
| "minLength": 1, | |
| "description": "Display name of the ingredient as entered by the user. May include descriptors (e.g. 'cheddar cheese, shredded'), cost annotations appended by the app (e.g. 'olive oil ($0.16)'), or the word '(optional)' to indicate an optional ingredient. When the parent ingredientEntry has quantityType 'SECTION', this field holds the section header label text." | |
| } | |
| } | |
| }, | |
| "quantity": { | |
| "type": "object", | |
| "description": "The measurement specification for an ingredient. For normal ingredients, both 'quantityType' and 'amount' are present. For section headers (quantityType='SECTION'), only 'quantityType' is present \u2014 'amount' is absent.", | |
| "required": [ | |
| "quantityType" | |
| ], | |
| "additionalProperties": false, | |
| "properties": { | |
| "quantityType": { | |
| "type": "string", | |
| "description": "The unit of measurement. 'SECTION' is a special non-unit value meaning this ingredient entry is a visual section divider/header rather than a measurable ingredient \u2014 when used, 'amount' must be omitted. All other values are true units of measure and require 'amount' to be present.", | |
| "enum": [ | |
| "SECTION", | |
| "ITEM", | |
| "TEASPOON", | |
| "TABLESPOON", | |
| "CUP", | |
| "FLUID_OUNCE", | |
| "MILLS", | |
| "CENTILITER", | |
| "DECILITER", | |
| "LITRES", | |
| "PINCH", | |
| "OUNCE", | |
| "POUND", | |
| "GRAMS", | |
| "KGS", | |
| "BOTTLE", | |
| "CAN", | |
| "BUNCH", | |
| "PACKET" | |
| ], | |
| "x-enum-descriptions": { | |
| "SECTION": "Not a unit \u2014 marks this ingredient row as a section header/divider. The ingredient.name field holds the label text. The 'amount' field must be absent.", | |
| "ITEM": "A countable whole item with no specific unit (e.g. '1 yellow onion', '2 green onions'). Use for eggs, cloves of garlic, cans when counted as items, etc.", | |
| "TEASPOON": "Teaspoon (tsp). US/imperial volume measure. 1 tablespoon = 3 teaspoons.", | |
| "TABLESPOON": "Tablespoon (Tbsp). US/imperial volume measure. 1 cup = 16 tablespoons.", | |
| "CUP": "Cup (c). US/imperial volume measure. 1 cup = 237 ml.", | |
| "FLUID_OUNCE": "Fluid ounce (fl oz). US/imperial volume measure. 1 cup = 8 fl oz.", | |
| "MILLS": "Millilitre (ml). Metric volume measure. Display label in the app UI is 'Mills'. 1 litre = 1000 ml.", | |
| "CENTILITER": "Centilitre (cl). Metric volume measure. 1 litre = 100 cl.", | |
| "DECILITER": "Decilitre (dl). Metric volume measure. 1 litre = 10 dl.", | |
| "LITRES": "Litre (L). Metric volume measure. Note the British/international spelling 'LITRES' (not LITERS).", | |
| "PINCH": "Pinch \u2014 an approximate small amount, typically used for salt, spices, or herbs.", | |
| "OUNCE": "Ounce (oz). US/imperial weight measure. 16 oz = 1 pound.", | |
| "POUND": "Pound (lb). US/imperial weight measure.", | |
| "GRAMS": "Gram (g). Metric weight measure. 1000 g = 1 kg.", | |
| "KGS": "Kilogram (kg). Metric weight measure. Note the plural abbreviation 'KGS' (not KILOGRAM, matching the UI label 'Kilogram').", | |
| "BOTTLE": "Bottle \u2014 an approximate container measure, typically used for wine, oil, sauces, etc.", | |
| "CAN": "Can \u2014 a standard tin/can, typically used for tinned tomatoes, beans, coconut milk, etc.", | |
| "BUNCH": "Bunch \u2014 a loose grouping, typically used for herbs, spring onions, asparagus, etc.", | |
| "PACKET": "Packet \u2014 a sealed package unit, typically used for pasta, yeast, spices, etc." | |
| } | |
| }, | |
| "amount": { | |
| "type": "number", | |
| "exclusiveMinimum": 0, | |
| "description": "The numeric quantity of the ingredient in the specified unit. Must be a positive number. May be a whole integer (e.g. 1, 2, 16) or a decimal fraction (e.g. 0.5, 1.5, 0.3333333333333333). The app stores fractions as IEEE 754 doubles \u2014 one-third is stored as the repeating decimal 0.3333333333333333. Must be present for all quantityType values except 'SECTION', where it must be absent." | |
| } | |
| }, | |
| "if": { | |
| "properties": { | |
| "quantityType": { | |
| "const": "SECTION" | |
| } | |
| } | |
| }, | |
| "then": { | |
| "description": "When quantityType is SECTION, amount must not be present.", | |
| "not": { | |
| "required": [ | |
| "amount" | |
| ] | |
| } | |
| }, | |
| "else": { | |
| "description": "For all non-SECTION quantityTypes, amount is required.", | |
| "required": [ | |
| "amount" | |
| ] | |
| } | |
| }, | |
| "stepEntry": { | |
| "type": "object", | |
| "description": "A single row in the instructions list. May be a cooking instruction paragraph or a section header that groups steps under a label. The 'isSection' boolean distinguishes the two: when true this is a section header (the 'step' text is the label); when false this is a normal instruction step.", | |
| "required": [ | |
| "uuid", | |
| "order", | |
| "step", | |
| "isSection" | |
| ], | |
| "additionalProperties": false, | |
| "properties": { | |
| "uuid": { | |
| "$ref": "#/definitions/uuid", | |
| "description": "Unique identifier for this step entry." | |
| }, | |
| "order": { | |
| "type": "integer", | |
| "minimum": 0, | |
| "description": "Zero-based display position of this entry within the steps list. Entries are rendered in ascending order. Values are contiguous starting from 0." | |
| }, | |
| "step": { | |
| "type": "string", | |
| "minLength": 1, | |
| "description": "The text content of this entry. When isSection=false, this is the full instruction text for one cooking step \u2014 typically one or a few sentences describing a single action. When isSection=true, this is the section header label text (e.g. 'For the sauce:', 'To serve:')." | |
| }, | |
| "isSection": { | |
| "type": "boolean", | |
| "description": "When false (the normal case), this entry is a cooking instruction step and 'step' contains the instruction text. When true, this entry is a visual section header that groups subsequent steps under a label; 'step' then contains the header label text. Section headers are displayed in bold and do not receive a step number in the app UI." | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment