Skip to main content

cooklang_format/
schema.rs

1//! Format a recipe as [schema.org/Recipe](https://schema.org/Recipe) JSON-LD.
2//!
3//! This is the shape search engines read, so the field names are fixed by the
4//! vocabulary rather than chosen here.
5
6use crate::quantity::grouped_quantity_fmt;
7use cooklang::{convert::Converter, model::Item, Recipe};
8use serde_json::{json, Value};
9use std::io;
10
11/// Write `recipe` as a schema.org `Recipe` JSON-LD object.
12///
13/// `pretty` indents the JSON. Nothing here can fail except the write itself,
14/// which `serde_json` reports as the underlying [`io::Error`].
15pub fn print_schema(
16    recipe: &Recipe,
17    name: &str,
18    scale: f64,
19    converter: &Converter,
20    writer: impl io::Write,
21    pretty: bool,
22) -> io::Result<()> {
23    let schema = create_schema_object(recipe, name, scale, converter);
24
25    if pretty {
26        serde_json::to_writer_pretty(writer, &schema)?;
27    } else {
28        serde_json::to_writer(writer, &schema)?;
29    }
30
31    Ok(())
32}
33
34/// Build the JSON-LD object. Infallible: every field is optional, and a
35/// missing or unparseable one is simply left out.
36fn create_schema_object(recipe: &Recipe, name: &str, scale: f64, converter: &Converter) -> Value {
37    let mut schema = json!({
38        "@context": "https://schema.org",
39        "@type": "Recipe",
40        "name": if scale != 1.0 {
41            format!("{name} (scaled {scale}x)")
42        } else {
43            name.to_string()
44        }
45    });
46
47    // Add description if present
48    if let Some(desc) = recipe.metadata.description() {
49        schema["description"] = json!(desc);
50    }
51
52    // Add tags as keywords
53    if let Some(tags) = recipe.metadata.tags() {
54        schema["keywords"] = json!(tags.join(", "));
55    }
56
57    // Add author information if present
58    if let Some(author) = recipe.metadata.author() {
59        let author_name = author.name().unwrap_or(author.url().unwrap_or(""));
60        schema["author"] = json!({
61            "@type": "Person",
62            "name": author_name
63        });
64    }
65
66    // Add source URL if present
67    if let Some(source) = recipe.metadata.source() {
68        if let Some(url) = source.url() {
69            schema["url"] = json!(url);
70        } else if let Some(name) = source.name() {
71            schema["url"] = json!(name);
72        }
73    }
74
75    // Add servings/yield
76    if let Some(servings) = recipe.metadata.servings() {
77        use cooklang::metadata::Servings;
78        match servings {
79            Servings::Number(n) => {
80                // Apply scaling to servings
81                let scaled_servings = if scale != 1.0 {
82                    (n as f64 * scale).round() as u32
83                } else {
84                    n
85                };
86                schema["recipeYield"] = json!(format!("{} servings", scaled_servings));
87            }
88            Servings::Text(text) => {
89                schema["recipeYield"] = json!(text);
90            }
91        }
92    }
93
94    // Add timing information
95    add_time_fields(&mut schema, recipe);
96
97    // Add nutrition information if present
98    add_nutrition_info(&mut schema, recipe);
99
100    // Add ingredients
101    let ingredients = create_ingredients_list(recipe, converter);
102    if !ingredients.is_empty() {
103        schema["recipeIngredient"] = json!(ingredients);
104    }
105
106    // Add cookware as tools
107    let tools = create_tools_list(recipe, converter);
108    if !tools.is_empty() {
109        schema["tool"] = json!(tools);
110    }
111
112    // Add instructions
113    let instructions = create_instructions_list(recipe);
114    if !instructions.is_empty() {
115        schema["recipeInstructions"] = json!(instructions);
116    }
117
118    // Add recipe category and cuisine if available
119    if let Some(category) = recipe.metadata.map.get("category") {
120        if let Some(cat_str) = category.as_str() {
121            schema["recipeCategory"] = json!(cat_str);
122        }
123    }
124
125    if let Some(cuisine) = recipe.metadata.map.get("cuisine") {
126        if let Some(cuisine_str) = cuisine.as_str() {
127            schema["recipeCuisine"] = json!(cuisine_str);
128        }
129    }
130
131    // Add image if present
132    if let Some(image) = recipe.metadata.map.get("image") {
133        if let Some(image_str) = image.as_str() {
134            schema["image"] = json!(image_str);
135        }
136    }
137
138    schema
139}
140
141fn add_time_fields(schema: &mut Value, recipe: &Recipe) {
142    // Get prep time from metadata
143    if let Some(prep_time_val) = recipe.metadata.get("prep time") {
144        if let Some(prep_time_str) = prep_time_val.as_str() {
145            schema["prepTime"] = json!(format_iso_duration(prep_time_str));
146        }
147    }
148
149    // Get cook time from metadata
150    if let Some(cook_time_val) = recipe.metadata.get("cook time") {
151        if let Some(cook_time_str) = cook_time_val.as_str() {
152            schema["cookTime"] = json!(format_iso_duration(cook_time_str));
153        }
154    }
155
156    // Calculate total time if both prep and cook times are available
157    let has_prep = recipe.metadata.get("prep time").is_some();
158    let has_cook = recipe.metadata.get("cook time").is_some();
159    if has_prep && has_cook {
160        // Simplified - just sum the minutes from both times
161        let mut total_minutes = 0;
162        if let Some(prep_val) = recipe.metadata.get("prep time").and_then(|v| v.as_str()) {
163            total_minutes += extract_number(prep_val).unwrap_or(0);
164        }
165        if let Some(cook_val) = recipe.metadata.get("cook time").and_then(|v| v.as_str()) {
166            total_minutes += extract_number(cook_val).unwrap_or(0);
167        }
168        schema["totalTime"] = json!(format!("PT{}M", total_minutes));
169    }
170}
171
172fn format_iso_duration(time_str: &str) -> String {
173    // Convert time strings like "30 minutes" or "1 hour" to ISO 8601 duration format
174    // This is a simplified implementation
175    let lower = time_str.to_lowercase();
176
177    if lower.contains("hour") {
178        if let Some(hours) = extract_number(&lower) {
179            return format!("PT{hours}H");
180        }
181    } else if lower.contains("min") {
182        if let Some(minutes) = extract_number(&lower) {
183            return format!("PT{minutes}M");
184        }
185    }
186
187    // Fallback: assume minutes if just a number
188    if let Some(minutes) = extract_number(&lower) {
189        return format!("PT{minutes}M");
190    }
191
192    "PT0M".to_string()
193}
194
195fn extract_number(s: &str) -> Option<i32> {
196    s.chars()
197        .filter(|c| c.is_numeric())
198        .collect::<String>()
199        .parse::<i32>()
200        .ok()
201}
202
203fn add_nutrition_info(schema: &mut Value, recipe: &Recipe) {
204    let mut nutrition = json!({
205        "@type": "NutritionInformation"
206    });
207
208    let mut has_nutrition = false;
209
210    // Check for various nutrition fields in metadata
211    if let Some(calories) = recipe.metadata.map.get("calories") {
212        if let Some(cal_str) = calories.as_str() {
213            nutrition["calories"] = json!(format!("{} calories", cal_str));
214            has_nutrition = true;
215        } else if let Some(cal_num) = calories.as_u64() {
216            nutrition["calories"] = json!(format!("{} calories", cal_num));
217            has_nutrition = true;
218        }
219    }
220
221    if let Some(protein) = recipe.metadata.map.get("protein") {
222        if let Some(prot_str) = protein.as_str() {
223            nutrition["proteinContent"] = json!(prot_str);
224            has_nutrition = true;
225        }
226    }
227
228    if let Some(fat) = recipe.metadata.map.get("fat") {
229        if let Some(fat_str) = fat.as_str() {
230            nutrition["fatContent"] = json!(fat_str);
231            has_nutrition = true;
232        }
233    }
234
235    if let Some(carbs) = recipe.metadata.map.get("carbohydrates") {
236        if let Some(carbs_str) = carbs.as_str() {
237            nutrition["carbohydrateContent"] = json!(carbs_str);
238            has_nutrition = true;
239        }
240    }
241
242    if let Some(fiber) = recipe.metadata.map.get("fiber") {
243        if let Some(fiber_str) = fiber.as_str() {
244            nutrition["fiberContent"] = json!(fiber_str);
245            has_nutrition = true;
246        }
247    }
248
249    if let Some(sugar) = recipe.metadata.map.get("sugar") {
250        if let Some(sugar_str) = sugar.as_str() {
251            nutrition["sugarContent"] = json!(sugar_str);
252            has_nutrition = true;
253        }
254    }
255
256    if let Some(sodium) = recipe.metadata.map.get("sodium") {
257        if let Some(sodium_str) = sodium.as_str() {
258            nutrition["sodiumContent"] = json!(sodium_str);
259            has_nutrition = true;
260        }
261    }
262
263    if has_nutrition {
264        schema["nutrition"] = nutrition;
265    }
266}
267
268fn create_ingredients_list(recipe: &Recipe, converter: &Converter) -> Vec<String> {
269    let mut ingredients = Vec::new();
270
271    for entry in recipe.group_ingredients(converter) {
272        let ingredient = entry.ingredient;
273
274        if !ingredient.modifiers().should_be_listed() {
275            continue;
276        }
277
278        let mut ingredient_text = String::new();
279
280        if !entry.quantity.is_empty() {
281            ingredient_text.push_str(&grouped_quantity_fmt(&entry.quantity));
282            ingredient_text.push(' ');
283        }
284
285        ingredient_text.push_str(&ingredient.display_name());
286
287        if ingredient.modifiers().is_optional() {
288            ingredient_text.push_str(" (optional)");
289        }
290
291        if let Some(note) = &ingredient.note {
292            ingredient_text.push_str(&format!(", {note}"));
293        }
294
295        ingredients.push(ingredient_text);
296    }
297
298    ingredients
299}
300
301fn create_tools_list(recipe: &Recipe, converter: &Converter) -> Vec<String> {
302    let mut tools = Vec::new();
303
304    for item in recipe.group_cookware(converter) {
305        let cw = item.cookware;
306
307        let mut tool_text = String::new();
308
309        if !item.quantity.is_empty() {
310            tool_text.push_str(&grouped_quantity_fmt(&item.quantity));
311            tool_text.push(' ');
312        }
313
314        tool_text.push_str(cw.display_name());
315
316        if cw.modifiers().is_optional() {
317            tool_text.push_str(" (optional)");
318        }
319
320        if let Some(note) = &cw.note {
321            tool_text.push_str(&format!(", {note}"));
322        }
323
324        tools.push(tool_text);
325    }
326
327    tools
328}
329
330fn build_step(recipe: &Recipe, step: &cooklang::model::Step, step_number: usize) -> Value {
331    let mut step_text = String::new();
332    let mut total_seconds: f64 = 0.0;
333    let mut has_timers = false;
334
335    for item in &step.items {
336        match item {
337            Item::Text { value } => {
338                step_text.push_str(value);
339            }
340            &Item::Ingredient { index } => {
341                let igr = &recipe.ingredients[index];
342                step_text.push_str(&igr.display_name());
343            }
344            &Item::Cookware { index } => {
345                let cw = &recipe.cookware[index];
346                step_text.push_str(&cw.name);
347            }
348            &Item::Timer { index } => {
349                let t = &recipe.timers[index];
350                if let Some(name) = &t.name {
351                    step_text.push_str(&format!("{name} for "));
352                }
353                if let Some(quantity) = &t.quantity {
354                    step_text.push_str(&quantity.to_string());
355
356                    // Accumulate timer duration for timeRequired.
357                    // Range values are averaged (e.g. 15-30 min → 22.5 min).
358                    // Note: the parser produces Range only from scaling operations,
359                    // not from syntax like ~{15-30%min} (which parses as Text).
360                    let seconds = match quantity.value() {
361                        cooklang::quantity::Value::Number(n) => Some(n.value()),
362                        cooklang::quantity::Value::Range { start, end } => {
363                            Some((start.value() + end.value()) / 2.0)
364                        }
365                        cooklang::quantity::Value::Text(_) => None,
366                    };
367                    if let Some(val) = seconds {
368                        let multiplier = match quantity.unit().map(|u| u.to_lowercase()).as_deref()
369                        {
370                            Some("s" | "sec" | "second" | "seconds") => Some(1.0),
371                            Some("h" | "hr" | "hour" | "hours") => Some(3600.0),
372                            // Cooklang timers without an explicit unit default to minutes
373                            Some("m" | "min" | "minute" | "minutes") | None => Some(60.0),
374                            Some(_) => None, // unknown unit — skip rather than guess
375                        };
376                        if let Some(m) = multiplier {
377                            total_seconds += val * m;
378                            has_timers = true;
379                        }
380                    }
381                }
382            }
383            &Item::InlineQuantity { index } => {
384                let q = &recipe.inline_quantities[index];
385                step_text.push_str(&q.to_string());
386            }
387        }
388    }
389
390    let mut instruction = json!({
391        "@type": "HowToStep",
392        "position": step_number,
393        "text": step_text.trim()
394    });
395
396    // Add timeRequired if the step has timers with a positive total duration.
397    // The total_secs > 0 guard also prevents emitting a bare "PT" string
398    // when all timers round to zero (e.g. ~{0%minutes}).
399    let total_secs = total_seconds.round() as i64;
400    if has_timers && total_secs > 0 {
401        let hours = total_secs / 3600;
402        let mins = (total_secs % 3600) / 60;
403        let secs = total_secs % 60;
404
405        let mut dur = "PT".to_string();
406        if hours > 0 {
407            dur.push_str(&format!("{hours}H"));
408        }
409        if mins > 0 {
410            dur.push_str(&format!("{mins}M"));
411        }
412        if secs > 0 {
413            dur.push_str(&format!("{secs}S"));
414        }
415        instruction["timeRequired"] = json!(dur);
416    }
417
418    instruction
419}
420
421fn create_instructions_list(recipe: &Recipe) -> Vec<Value> {
422    let mut instructions = Vec::new();
423    // Step position is global across all sections, consistent with Google's
424    // structured data examples for Recipe.
425    let mut step_number = 0;
426
427    for section in &recipe.sections {
428        let mut section_steps = Vec::new();
429        for content in &section.content {
430            if let cooklang::Content::Step(step) = content {
431                step_number += 1;
432                section_steps.push(build_step(recipe, step, step_number));
433            }
434        }
435
436        if section_steps.is_empty() {
437            continue;
438        }
439
440        if let Some(name) = &section.name {
441            // Wrap in HowToSection only for named sections
442            let section_obj = json!({
443                "@type": "HowToSection",
444                "name": name,
445                "itemListElement": section_steps
446            });
447            instructions.push(section_obj);
448        } else {
449            // Unnamed sections emit steps flat
450            instructions.extend(section_steps);
451        }
452    }
453
454    instructions
455}