Desert Dog Recipe (DDR) File Format Reference
Implementation reference: DesertDog/recipe-site current main
Internal format identifiers: desertdog.recipe-package.v2 containing desertdog.recipe.v2
Last verified: 2026-09-28
This document describes the native .ddr format exactly as the current Recipe Site importer and exporter implement it. It distinguishes:
- values emitted by the exporter;
- values that the importer actually restores through review and Save;
- compatibility values that are accepted but not normally emitted; and
- values intentionally excluded because they belong to an installation or user rather than the recipe.
There is no separate published JSON Schema. The PHP importer/exporter is the authoritative implementation.
1. What a DDR file is
A .ddr file is a ZIP archive with a different filename extension. A normal package has this layout:
example-recipe.ddr
├── recipe.json
├── manifest.json
├── images/
│ └── assets/images/recipes/123-example-recipe/example.jpg
└── audio/
└── recipes/example-recipe/attachment.mp3
Only recipe.json is strictly required by the current package reader. The official exporter always writes manifest.json; portable audio requires its audio entry. Image and audio directories are optional.
The archive and both JSON files are UTF-8. Official output uses pretty-printed JSON, unescaped Unicode, and unescaped / characters. JSON whitespace is not significant.
Do not confuse these formats:
| File | Purpose |
|---|---|
.ddr |
One native recipe/content item, ZIP-packaged with optional media |
.recipe.json |
The same native recipe data as plain JSON, without packaged media |
Recipe Site collection .zip |
An outer ZIP containing one or more top-level .ddr files |
2. Required minimum
The package reader requires a root-level file named exactly recipe.json. The JSON validator requires only these values:
{
"format": "desertdog.recipe.v2",
"recipe": {
"title": "Example Recipe",
"slug": "example-recipe"
}
}
This is sufficient to open an import preview. To save a normal recipe without further editing, at least one usable category is also required by the editor. Informational content (tip, guide, technique, or reference) does not have the normal-recipe category requirement.
Recommended minimal saveable recipe:
{
"format": "desertdog.recipe.v2",
"recipe": {
"title": "Example Recipe",
"slug": "example-recipe",
"content_type": "recipe"
},
"categories": [
{ "name": "Main Dishes", "slug": "main-dishes", "is_primary": 1 }
],
"ingredients": [
{ "sort_order": 1, "raw_text": "1 cup example ingredient" }
],
"instructions": [
{ "step_number": 1, "instruction_text": "Prepare and serve." }
]
}
3. recipe.json root object
| Key | Type | Official export | Import behavior |
|---|---|---|---|
format |
string | Always | Required. Must be desertdog.recipe.v2. Other versions are rejected. |
exported_at |
ISO-8601 string | Always | Informational; ignored on Save |
source_site |
object | Always | Informational; ignored on Save |
recipe |
object | Always | Required; see below |
categories |
array | Always, possibly empty | Matched by local slug, then case-insensitive name |
tags |
array | Always, possibly empty | Presence is significant; see Tags |
ingredients |
array | Always, possibly empty | Restored as structured rows when visible text is unchanged |
instructions |
array | Always, possibly empty | Restored as structured rows when visible text is unchanged |
notes |
array | Always, possibly empty | Restored as structured rows when visible text is unchanged |
sections |
array | Always, possibly empty | Restored as flexible extra sections |
nutrition |
object | Only when populated | Supported fields are restored; legacy-only fields are ignored |
related_recipes |
array | Always, possibly empty | Linked only when target slug/title exists locally |
modules |
array | Always, possibly empty | Approved calculator and variant module rows are restored |
external_imports |
array | Always, possibly empty | Source-scoped metadata and all external rating rows are restored |
images |
array | Always, possibly empty | Describes and maps package images |
external_import |
object | Accepted; not emitted by native exporter | Preserves source-scoped metadata and optional rating semantics |
Unknown root keys are tolerated and ignored. For example, old conversion packages may contain old_desertdog_import; it is not part of the current saved recipe model.
The exporter recursively changes empty strings to null, then omits null/empty properties from JSON objects. Therefore optional blank properties normally do not appear at all. Zero and false are preserved. Importers should tolerate omitted optional keys.
source_site
Official form:
{
"name": "Your Kitchen Library"
}
Only name is emitted. The current importer does not save this object.
4. recipe object
4.1 Fields restored by import
| Key | Type | Meaning / accepted values |
|---|---|---|
title |
string | Required, nonblank |
slug |
string | Required, nonblank; normalized by the editor and made unique for Add New |
content_type |
string | recipe, tip, guide, technique, or reference; missing/unknown becomes recipe |
status |
string | draft or published; missing/unknown becomes published |
subtitle |
string | Optional subtitle |
story |
string | Long-form story/introduction |
description_short |
string | Short public description |
main_image |
string | Public image path; package staging works when it exactly matches an images[].path |
main_image_credit |
string | Image credit text |
audio_path |
string | Local/public MP3 path; packaged audio can replace it with a staged path |
audio_title |
string | Display title for audio |
audio_download_name |
string | Suggested audio download filename |
video_url |
string | Remote/video URL |
video_title |
string | Video title |
video_caption |
string | Video caption |
video_download_path |
string | Downloadable video path or URL metadata; DDR does not bundle video bytes |
video_download_name |
string | Suggested video download filename |
prep_time_minutes |
integer | Preparation minutes |
cook_time_minutes |
integer | Cooking minutes |
total_time_minutes |
integer | Total minutes |
total_time_text |
string | Human-readable total time when useful |
soak_time_minutes |
integer | Soaking minutes |
extra_time_1_label |
string | Label such as Resting, Chilling, or Fermentation |
extra_time_1_minutes |
integer | First additional time in minutes |
extra_time_1_text |
string | Human-readable first additional time/details |
extra_time_2_label |
string | Second additional time label |
extra_time_2_minutes |
integer | Second additional time in minutes |
extra_time_2_text |
string | Human-readable second additional time/details |
yield_text |
string | Descriptive yield, such as 2 loaves or 1 pint |
servings |
string or number | Serving count/text; stored as text |
difficulty |
string | Free text; there is no fixed DDR enum |
cuisine_nationality |
string | Cuisine/nationality text |
source_name |
string | Source publication/site/person |
source_url |
string | Source URL |
attributed_to |
string | Recipe attribution/author |
copyright_text |
string | Copyright statement |
Times are stored as minutes. Use extra_time_1_* and extra_time_2_* for fermentation, rising, chilling, resting, cooling, inactive time, or any other named duration. There is no dedicated fermentation field.
4.2 Database fields deliberately excluded from v2 export
The current DDR format uses an explicit recipe-field allowlist. It does not emit installation-local id, created_at, or updated_at values. Legacy rest_time_minutes is migrated during export or import into the first free named extra-time slot with the label Resting; the obsolete key itself is not emitted or saved.
5. Categories
Each categories item emitted by the exporter can contain:
| Key | Type | Import use |
|---|---|---|
name |
string | Match fallback and new-category display |
slug |
string | Preferred local match |
is_primary |
integer/bool | Directly restores the primary-category selection |
parent_name |
string | Used when presenting an unmatched category as Parent: Child |
parent_slug |
string | Exported; not used for matching |
The official exporter orders the primary category first and sets is_primary. The importer honors that flag. If no item is flagged, Save falls back to the first selected category, so hand-authored packages should still list the intended primary first.
Matching order is:
- exact local category slug;
- case-insensitive local category name;
- otherwise add the name to the editor's New Categories box for review.
6. Tags
Each tag can contain:
| Key | Type | Import use |
|---|---|---|
name |
string | Used to find or create the tag |
slug |
string | Preferred lookup; normalized if necessary |
Legacy/hand-authored id and description keys are tolerated but ignored. New v2 exports emit only name and slug.
Root-key presence matters:
- omit
tagsto leave existing tags unchanged during overwrite; - use
"tags": []to explicitly clear tags during overwrite; - provide tag objects to replace attachments with the matched/created tags.
7. Ingredients
Each ingredients row supports:
| Key | Type | Import behavior |
|---|---|---|
id |
integer/null | Ignored |
recipe_id |
integer/null | Ignored |
sort_order |
integer | Preserved; defaults to array position + 1 |
section_name |
string/null | Ingredient subsection heading |
raw_text |
string | Required for a usable row |
amount |
string/null | Preserved if the review text is not edited |
unit |
string/null | Preserved if the review text is not edited |
ingredient_name |
string/null | Preserved if the review text is not edited |
preparation_note |
string/null | Preserved if the review text is not edited |
optional_flag |
bool/int | Truthy becomes 1, otherwise 0 |
raw_text is the authoritative visible line. Structured fields survive only when the imported ingredient textarea remains textually unchanged. If the user edits it, rows are rebuilt from visible lines and only section_name plus raw_text are retained.
Ingredient scaling directives
Scaling controls live inside raw_text; they are not separate JSON keys. Supported directives may appear anywhere in the line:
| Directive | Meaning |
|---|---|
[noscale], [no-scale], [non-scaling], [do-not-scale] |
Never scale this line |
[nograms], [no-grams], [no gram], [no grams], [suppress-grams] |
Scale quantity but suppress generated/updated gram equivalents |
[scaled: replacement text] |
Use the replacement for every non-1× scale |
[scale=0.25: text] |
Exact ¼× text; factor aliases .25 and 1/4 work |
[scale=0.5: text] |
Exact ½× text; factor aliases .5 and 1/2 work |
[scale=1: text] |
Exact 1× text |
[scale=2: text] |
Exact 2× text |
[scale=3: text] |
Exact 3× text |
Unknown bracketed text is left unchanged.
Example:
{
"sort_order": 1,
"section_name": "Dough",
"raw_text": "1 packet yeast [noscale]",
"amount": "1",
"unit": "packet",
"ingredient_name": "yeast",
"optional_flag": 0
}
8. Instructions
Each instructions row supports:
| Key | Type | Import behavior |
|---|---|---|
id |
integer/null | Ignored |
recipe_id |
integer/null | Ignored |
step_number |
integer | Preserved; defaults to array position + 1 |
section_name |
string/null | Method subsection heading |
instruction_text |
string | Required for a usable row |
image_path |
string/null | Structured primary step image; preserved if review text is unchanged |
On preview, image_path is represented visibly as an image tag if the same path is not already in instruction_text. If the user edits instruction text or uploads new images, instructions are rebuilt from the visible editor representation.
Inline image tag
Instructions and extra-section bodies support:
[image:/assets/images/path/photo.jpg]
[image:/assets/images/path/photo.jpg|scale=75]
scale is an integer percentage clamped to 10–200; default is 100. In instructions, place the tag on its own line. Extra sections can preserve a tag inline with surrounding text. The image path must also have a matching images[].path and valid package entry if its bytes should travel in the DDR.
9. Notes
Each notes row supports:
| Key | Type | Import behavior |
|---|---|---|
id |
integer/null | Ignored |
recipe_id |
integer/null | Ignored |
sort_order |
integer | Preserved; defaults to array position + 1 |
note_type |
string | Preserved; defaults to note |
note_text |
string | Required for a usable row |
As with ingredients, the structured row survives only when visible note text is unchanged during review.
10. Flexible extra sections
Each sections row supports:
| Key | Type | Import behavior |
|---|---|---|
id |
integer/null | Ignored |
section_type |
string | Required for a usable row; see validation below |
title |
string/null | Optional custom heading |
body |
string | Required, nonblank |
sort_order |
integer | Exported, but Save reorders by array position |
section_type must begin with an ASCII letter and then contain at most 63 more ASCII letters, digits, _, or -. Invalid source types are sanitized during native preview; an empty result is skipped. Custom valid types are supported.
Known types and their default headings include:
section_type |
Default heading / behavior |
|---|---|
notes |
Notes |
tips |
Tips |
variations or variation |
Variations |
storage |
Storage |
make_ahead |
Make Ahead |
serving_suggestions |
Serving Suggestions |
troubleshooting |
Troubleshooting |
source |
Source |
equipment |
Equipment |
optional |
Optional |
timing_note |
Timing Notes |
html_table |
Uses the controlled HTML-table renderer |
| any other valid type | Humanized from the type unless title is supplied |
Other production/legacy types known to the editor include fermentation_note, optional_finish, photos, reference, starter, technique, and with. A custom title overrides the default heading.
Section body text may contain the site's safe Markdown subset and inline image tags. It is stored as plain text; executable PHP/JavaScript is never a valid DDR feature.
11. Nutrition
The current Save path supports these nutrition keys:
| Key | Unit/type |
|---|---|
serving_size |
string, e.g. 1 slice |
serving_basis |
per_serving, per_recipe, or unknown |
calories |
number |
fat_g |
grams |
saturated_fat_g |
grams |
trans_fat_g |
grams |
cholesterol_mg |
milligrams |
sodium_mg |
milligrams |
potassium_mg |
milligrams |
carbs_g |
grams |
fiber_g |
grams |
sugar_g |
grams |
added_sugar_g |
grams |
protein_g |
grams |
calcium_mg |
milligrams |
iron_mg |
milligrams |
magnesium_mg |
milligrams |
folate_mcg |
micrograms |
phosphorus_mg |
milligrams |
zinc_mg |
milligrams |
vitamin_d_mcg |
micrograms |
vitamin_a_mcg |
micrograms |
vitamin_c_mg |
milligrams |
alcohol_g |
grams |
caffeine_mg |
milligrams |
nutrition_note |
string |
nutrition_text |
string; legacy/free-form nutrition text |
Database id and recipe_id are not exported. Numeric-looking strings are accepted by the editor and converted to numbers. Blank values are omitted.
12. Related recipes
Official v2 rows contain:
| Key | Type | Import use |
|---|---|---|
related_title |
string | Fallback local lookup |
related_slug |
string | Preferred local lookup |
description |
string | Preserved relationship text |
sort_order |
integer | Exported; Save uses array order |
Legacy/hand-authored id and related_recipe_id keys are tolerated but ignored because those identities are installation-local.
The importer resolves by exact slug first, then exact title. If no local recipe matches, it warns and does not create the link. Use {recipe} inside description where the linked recipe title should appear; if omitted, the renderer appends the link.
Within a Recipe Site collection bulk import, the bulk path performs an additional repair pass for relationships among recipes imported in the same batch.
13. Images
An official native-export images row contains:
| Key | Type | Meaning |
|---|---|---|
path |
string | Original public path referenced by recipe content |
relative_path |
string | Original path without leading /; informational |
exists |
bool | Whether the exporter found the source file |
size_bytes |
integer/null | Source size |
package_path |
string/null | Exact ZIP entry under images/ |
The importer uses path plus package_path. Other image-row keys from conversion tools, such as filename, role, included_in_package, source_url, or mime_type, are tolerated but not required.
Rules for packaged images:
package_pathmust start withimages/and must not contain..;- the ZIP entry name must exactly match
package_path; - accepted extensions/MIME pairs are JPEG (
.jpg,.jpeg), PNG (.png), WebP (.webp), and GIF (.gif); - bytes must pass MIME validation and the configured per-image upload-size limit;
- duplicate target names are safely renamed during staging;
- every recipe reference that should be rewritten must exactly match
images[].path.
Image references are collected by the exporter from recipe.main_image, ingredient raw_text, instruction text and image_path, note text, and section bodies. Remote http:, https:, and data: images are not packaged by the normal native exporter.
Recommended handcrafted image mapping:
{
"recipe": {
"main_image": "/assets/images/import/my-photo.jpg"
},
"images": [
{
"path": "/assets/images/import/my-photo.jpg",
"package_path": "images/my-photo.jpg",
"exists": true,
"size_bytes": 123456
}
]
}
Then store the actual bytes at ZIP entry images/my-photo.jpg.
14. Audio
Audio metadata lives in recipe; packaged audio metadata lives in manifest.json. The normal exporter bundles at most one local MP3 attachment.
Rules:
- source/public path must be under
/assets/audio/and end in.mp3for official export; - package path must match
audio/[A-Za-z0-9._/-]+.mp3and contain no..; - the file must have an ID3 header or MPEG frame sync and an accepted MP3 MIME type;
- default embedded-audio limit is 64 MiB, configurable with
MAX_AUDIO_UPLOAD_MB; - if valid, import stages the bytes and replaces
recipe.audio_pathwith the staged local path.
DDR does not package video files.
15. External import metadata and ratings
The current DDR format emits external_imports, a list with one item per source format. Each item preserves its metadata plus every source-scoped rating row:
{
"external_imports": [
{
"source_format": "mastercook",
"metadata": { "foreign_categories": ["Dinner"] },
"ratings": [
{
"value": 4,
"scale": 5,
"label": "Family Favorite",
"source_value": "4/5",
"sort_order": 0
}
]
}
]
}
source_format is normalized to lowercase [a-z0-9_.-], with other runs changed to _. metadata may contain arbitrary JSON. A rating is saved only when value and a positive scale are numeric. label, source_value, and sort_order are optional. DDR replacement affects source-scoped external metadata/ratings only; it never replaces a user's personal site rating.
The singular external_import object remains accepted for compatibility with individual external-format adapters:
This accepted root object preserves metadata from another recipe application:
{
"external_import": {
"source_format": "mastercook",
"metadata": {
"foreign_categories": ["Dinner"],
"favorite": true
},
"rating": {
"value": 4,
"scale": 5,
"label": "Family Favorite",
"source_value": "4/5"
},
"rating_import_mode": "average"
}
}
| Key | Rules |
|---|---|
source_format |
Required to apply the object; normalized to lowercase [a-z0-9_.-], with other runs changed to _ |
metadata |
Arbitrary JSON object preserved source-scoped |
rating |
Optional object; numeric value and positive numeric scale are required to apply a rating |
rating.label |
Optional label |
rating.source_value |
Optional original display value |
rating_import_mode |
average, user, or ignore; invalid/missing becomes average |
average stores a source-scoped external rating. user rounds the normalized value to a whole 1–5 rating for the importing user. ignore preserves metadata without applying the rating.
16. Modules and variants
The current DDR format emits a modules list. Each row supports:
| Key | Type | Meaning |
|---|---|---|
module_key |
string | Required approved key |
title |
string/null | Optional display title |
settings_json |
JSON string/null | Canonical module settings; hand-authored input may instead provide a settings object |
sort_order |
integer | Display order |
is_enabled |
integer/bool | Whether the module is enabled |
Approved portable keys are:
sauerkraut_salt_calculatorvegetable_steaming_schedulerrecipe_variant_switcher
Variants are stored in the recipe_variant_switcher module's settings; there is no separate variants root key. Unknown module keys are rejected. DDR data can select only backend-approved renderers and cannot supply executable PHP, JavaScript, or HTML.
17. manifest.json
Official structure:
{
"format": "desertdog.recipe-package.v2",
"created_at": "2026-09-28T12:00:00+00:00",
"recipe_slug": "example-recipe",
"recipe_title": "Example Recipe",
"recipe_json": "recipe.json",
"image_count": 1,
"included_image_count": 1,
"images": [
{
"path": "/assets/images/recipes/123-example-recipe/example.jpg",
"package_path": "images/assets/images/recipes/123-example-recipe/example.jpg",
"included": true,
"exists": true,
"size_bytes": 123456
}
],
"audio": {
"path": "/assets/audio/recipes/example.mp3",
"package_path": "audio/recipes/example.mp3",
"included": true,
"exists": true,
"size_bytes": 2345678
}
}
| Key | Type | Notes |
|---|---|---|
format |
string | Official value desertdog.recipe-package.v2 |
created_at |
ISO-8601 string | UTC creation time |
recipe_slug |
string | Summary/provenance |
recipe_title |
string | Summary/provenance |
recipe_json |
string | Official value recipe.json |
image_count |
integer | Number of image manifest rows, including missing source files |
included_image_count |
integer | Number actually included |
images |
array | Manifest copies of image inclusion status |
audio |
object | Optional; omitted when the recipe has no audio_path |
The current reader does not enforce the manifest format or counts. Image extraction is driven by recipe.json.images, while audio extraction is driven by manifest.json.audio. A malformed manifest produces a warning rather than blocking a package whose recipe.json is valid, but audio cannot be transported without a usable manifest entry.
18. Complete example
This example shows every current round-trippable recipe field and every supported child structure. Real files should omit values that are not known rather than inventing them.
{
"format": "desertdog.recipe.v2",
"exported_at": "2026-09-28T12:00:00+00:00",
"source_site": {
"name": "Your Kitchen Library"
},
"recipe": {
"title": "Complete DDR Example",
"slug": "complete-ddr-example",
"content_type": "recipe",
"subtitle": "A format demonstration",
"story": "Long-form background and context.",
"description_short": "A concise public description.",
"main_image": "/assets/images/import/complete-main.jpg",
"main_image_credit": "Photo by Example Photographer",
"audio_path": "/assets/audio/example/complete.mp3",
"audio_title": "Listen to the recipe",
"audio_download_name": "complete-ddr-example.mp3",
"video_url": "https://example.com/watch/example",
"video_title": "Technique video",
"video_caption": "A demonstration of the method.",
"video_download_path": "https://example.com/download/example.mp4",
"video_download_name": "technique-video.mp4",
"prep_time_minutes": 15,
"cook_time_minutes": 30,
"total_time_minutes": 165,
"total_time_text": "2 hours 45 minutes, including resting",
"soak_time_minutes": 30,
"extra_time_1_label": "Resting",
"extra_time_1_minutes": 60,
"extra_time_1_text": "Rest for 1 hour",
"extra_time_2_label": "Cooling",
"extra_time_2_minutes": 30,
"extra_time_2_text": "Cool for about 30 minutes",
"yield_text": "2 loaves",
"servings": "12",
"difficulty": "Intermediate",
"cuisine_nationality": "Example Cuisine",
"source_name": "Example Cookbook",
"source_url": "https://example.com/recipe",
"attributed_to": "Example Author",
"copyright_text": "© 2026 Example Author"
},
"categories": [
{
"name": "Breads",
"slug": "breads",
"is_primary": 1,
"parent_name": "Baking",
"parent_slug": "baking"
},
{
"name": "Make Ahead",
"slug": "make-ahead",
"is_primary": 0
}
],
"tags": [
{ "name": "Freezer Friendly", "slug": "freezer-friendly" }
],
"ingredients": [
{
"sort_order": 1,
"section_name": "Dough",
"raw_text": "2 cups flour",
"amount": "2",
"unit": "cups",
"ingredient_name": "flour",
"preparation_note": null,
"optional_flag": 0
},
{
"sort_order": 2,
"section_name": "Dough",
"raw_text": "1 packet yeast [noscale]",
"amount": "1",
"unit": "packet",
"ingredient_name": "yeast",
"optional_flag": 0
},
{
"sort_order": 3,
"section_name": "Finish",
"raw_text": "Flaky salt, as needed",
"ingredient_name": "flaky salt",
"preparation_note": "for finishing",
"optional_flag": 1
}
],
"instructions": [
{
"step_number": 1,
"section_name": "Mix",
"instruction_text": "Combine the dough ingredients.",
"image_path": "/assets/images/import/complete-step-1.jpg"
},
{
"step_number": 2,
"section_name": "Bake",
"instruction_text": "Bake until deeply browned.\n[image:/assets/images/import/complete-step-2.jpg|scale=75]"
}
],
"notes": [
{
"sort_order": 1,
"note_type": "tip",
"note_text": "Cool completely before slicing."
}
],
"sections": [
{
"section_type": "equipment",
"title": "Equipment",
"body": "- Mixing bowl\n- Digital scale",
"sort_order": 1
},
{
"section_type": "variations",
"title": "Variations",
"body": "Add seeds or chopped herbs.",
"sort_order": 2
},
{
"section_type": "photos",
"title": "Additional Photos",
"body": "[image:/assets/images/import/complete-extra.jpg|scale=60]",
"sort_order": 3
}
],
"nutrition": {
"serving_size": "1 slice",
"serving_basis": "per_serving",
"calories": 210,
"fat_g": 5.5,
"saturated_fat_g": 1.2,
"trans_fat_g": 0,
"cholesterol_mg": 0,
"sodium_mg": 320,
"potassium_mg": 125,
"carbs_g": 36,
"fiber_g": 3,
"sugar_g": 2,
"added_sugar_g": 0,
"protein_g": 7,
"calcium_mg": 25,
"iron_mg": 2.1,
"magnesium_mg": 30,
"folate_mcg": 75,
"phosphorus_mg": 90,
"zinc_mg": 0.8,
"vitamin_d_mcg": 0,
"vitamin_a_mcg": 5,
"vitamin_c_mg": 0,
"alcohol_g": 0,
"caffeine_mg": 0,
"nutrition_note": "Estimated values."
},
"related_recipes": [
{
"related_title": "Example Soup",
"related_slug": "example-soup",
"description": "Serve with {recipe}.",
"sort_order": 1
}
],
"images": [
{
"path": "/assets/images/import/complete-main.jpg",
"package_path": "images/complete-main.jpg",
"exists": true,
"size_bytes": 250000
},
{
"path": "/assets/images/import/complete-step-1.jpg",
"package_path": "images/complete-step-1.jpg",
"exists": true,
"size_bytes": 180000
},
{
"path": "/assets/images/import/complete-step-2.jpg",
"package_path": "images/complete-step-2.jpg",
"exists": true,
"size_bytes": 190000
},
{
"path": "/assets/images/import/complete-extra.jpg",
"package_path": "images/complete-extra.jpg",
"exists": true,
"size_bytes": 175000
}
],
"modules": [
{
"module_key": "recipe_variant_switcher",
"title": "Choose a version",
"settings_json": "{\"default\":{\"key\":\"standard\",\"label\":\"Standard\"},\"variants\":[{\"key\":\"large\",\"label\":\"Large Batch\"}]}",
"sort_order": 1,
"is_enabled": 1
}
],
"external_imports": [
{
"source_format": "example_app",
"metadata": {
"original_id": "abc-123"
},
"ratings": [
{
"value": 4,
"scale": 5,
"label": "Imported rating",
"source_value": "4/5",
"sort_order": 0
}
]
}
]
}
The matching ZIP entries would be:
recipe.json
manifest.json
images/complete-main.jpg
images/complete-step-1.jpg
images/complete-step-2.jpg
images/complete-extra.jpg
audio/example/complete.mp3
19. Import and collision behavior
Normal single-recipe import is review-first:
DDR/JSON → validation and media staging → duplicate check → normal editor → explicit Save/Overwrite
Important behavior:
- duplicate matching uses normalized slug first, then exact title;
- Add New creates a unique slug and appends
Copyto a colliding title; - Overwrite keeps the existing recipe URL/slug;
- imported category IDs and related-recipe IDs are never trusted across installations;
- unmatched related recipes are skipped with a warning;
- package media is staged temporarily and promoted into the final per-recipe media directory on Save;
- empty/unknown optional properties do not block import;
- existing matches in a bulk collection are not silently overwritten.
External-format adapters may apply limited cleanup when obvious metadata was flattened into description_short, story, notes, or sections. This recovery is heuristic; DDR writers should always use the proper fields.
20. Recipe Site collection ZIP
A collection is not one multi-recipe DDR. It is an ordinary .zip whose non-directory entries are only top-level .ddr files:
recipe-site-export.zip
├── first-recipe.ddr
├── second-recipe.ddr
└── third-recipe.ddr
Rules enforced by the current importer:
- no folders/nested paths;
- every non-directory entry must end in
.ddr; - at most 1,000 raw ZIP entries and at most 500 DDR recipes;
- each DDR must be nonempty and no more than 150 MiB expanded;
- total expanded collection size must not exceed 1 GiB;
- collection transport upload limit is 1 GiB;
- temporary upload/batch data expires after six hours.
The collection review screen selects every recipe by default, supports Select All/Clear All, allows per-recipe Preview, and bulk-imports only the selected recipes.
21. Current fidelity limits
DDR is the highest-fidelity portable Recipe Site format, but v2 is not a raw database backup.
The current native exporter intentionally does not include:
- user favorites, Want to Try, Made It/cooking history, meal plans, shopping lists, or personal ratings;
- recipe revisions/audit history;
- video file bytes;
- installation-local permissions/settings.
Database IDs/timestamps and tag descriptions are also excluded because they are installation-local or are not recipe attachment data. Recipe status, named extra times, legacy rest duration, nutrition text, approved modules, calculator settings, variant-switcher settings, primary category, and source-scoped external metadata/ratings now round-trip in v2.
22. Authoring checklist
- Name the ZIP file with a
.ddrextension. - Put
recipe.jsonandmanifest.jsonat archive root. - Set
recipe.json.formatto the documented internal identifier,desertdog.recipe.v2. Obsolete pre-release files are intentionally unsupported. - Supply nonblank
recipe.titleandrecipe.slug. - Use a supported
content_type. - Set
is_primary: 1on the intended primary category, put it first as a fallback, and include names/slugs that can match the destination site. - Express all times as integer minutes; use the two named extra-time slots for special durations.
- Keep authoritative visible ingredient text in
raw_text. - Keep authoritative step text in
instruction_text. - Use valid
section_typetokens and nonblank section bodies. - Omit unknown values instead of inventing them.
- For every bundled image, make the recipe reference equal
images[].path, and makeimages[].package_pathequal the ZIP entry name. - For bundled audio, provide both recipe audio metadata and
manifest.json.audio. - Use only approved
modules[].module_keyvalues; keep variant data insiderecipe_variant_switchersettings. - Never place executable code in recipe content.
- Test by importing into the normal review screen before relying on the package as portable.
23. Authoritative implementation files
The behavior documented here is implemented primarily in:
www/includes/recipe-json-export.php
www/includes/recipe-package-export.php
www/includes/recipe-json-import-preview.php
www/includes/recipe-editor.php
www/includes/recipe-external-import.php
www/includes/recipe-modules.php
www/includes/content-types.php
www/includes/ingredient-scaling.php
www/includes/render/recipe-view.php
www/includes/import-upload-staging.php
sql/baseline/v1.sql
When code and this document disagree, the current importer/exporter code is authoritative.
