Documentation / Native DDR File Format

Current stable: v1.0.3. Updated public documentation for October 22, 2026. Get the current release. Release notes.

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:

  1. exact local category slug;
  2. case-insensitive local category name;
  3. 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 tags to 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.

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_path must start with images/ 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 .mp3 for official export;
  • package path must match audio/[A-Za-z0-9._/-]+.mp3 and 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_path with 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_calculator
  • vegetable_steaming_scheduler
  • recipe_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 Copy to 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 .ddr extension.
  • Put recipe.json and manifest.json at archive root.
  • Set recipe.json.format to the documented internal identifier, desertdog.recipe.v2. Obsolete pre-release files are intentionally unsupported.
  • Supply nonblank recipe.title and recipe.slug.
  • Use a supported content_type.
  • Set is_primary: 1 on 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_type tokens and nonblank section bodies.
  • Omit unknown values instead of inventing them.
  • For every bundled image, make the recipe reference equal images[].path, and make images[].package_path equal the ZIP entry name.
  • For bundled audio, provide both recipe audio metadata and manifest.json.audio.
  • Use only approved modules[].module_key values; keep variant data inside recipe_variant_switcher settings.
  • 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.