From 163e916dbca76ed31bcc44021800036aae1e5ecf Mon Sep 17 00:00:00 2001 From: Romain Hild Date: Fri, 5 Jun 2026 19:45:40 +0200 Subject: [PATCH] feat: display nutrition data from metadata Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-06-05-nutrition-display.md | 655 ++++++++++++++++++ .../2026-06-05-nutrition-display-design.md | 129 ++++ locales/de-DE/recipes.ftl | 10 + locales/en-US/recipes.ftl | 10 + locales/es-ES/recipes.ftl | 10 + locales/eu-ES/recipes.ftl | 10 + locales/fr-FR/recipes.ftl | 10 + locales/nl-NL/recipes.ftl | 10 + locales/sv-SE/recipes.ftl | 10 + src/server/builders.rs | 112 ++- src/server/templates.rs | 14 + templates/recipe.html | 76 ++ 12 files changed, 1055 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-06-05-nutrition-display.md create mode 100644 docs/superpowers/specs/2026-06-05-nutrition-display-design.md diff --git a/docs/superpowers/plans/2026-06-05-nutrition-display.md b/docs/superpowers/plans/2026-06-05-nutrition-display.md new file mode 100644 index 0000000..4bb535b --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-nutrition-display.md @@ -0,0 +1,655 @@ +# Nutrition Info Display — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display nutrition metadata stored in `.cook` recipes as a collapsible section in the web UI, supporting both flat top-level keys and the nested `nutrition:` mapping produced by the importer. + +**Architecture:** Add a `NutritionData` struct to `templates.rs` and a field on `RecipeMetadata`. Extract it in `builders.rs` via a shared `extract_nutrition` helper that handles both storage formats and suppresses the processed keys from the generic `custom` list. Render a native `
/` collapsible block in `recipe.html` using translated labels from all 7 locale files. + +**Tech Stack:** Rust, Askama templates, Tailwind CSS v3.4+, Fluent (FTL) i18n + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `src/server/templates.rs` | Add `NutritionData` struct + `nutrition` field on `RecipeMetadata` | +| Modify | `src/server/builders.rs` | Add `extract_nutrition()` helper; call it from both `RecipeMetadata` builder sites; exclude nutrition keys from `custom` | +| Modify | `templates/recipe.html` | Collapsible nutrition section after `#metadata-container` | +| Modify | `locales/en-US/recipes.ftl` | 10 new translation keys | +| Modify | `locales/fr-FR/recipes.ftl` | French translations | +| Modify | `locales/de-DE/recipes.ftl` | German translations | +| Modify | `locales/es-ES/recipes.ftl` | Spanish translations | +| Modify | `locales/eu-ES/recipes.ftl` | Basque translations | +| Modify | `locales/nl-NL/recipes.ftl` | Dutch translations | +| Modify | `locales/sv-SE/recipes.ftl` | Swedish translations | + +--- + +## Task 1: Add `NutritionData` struct to `templates.rs` + +**Files:** +- Modify: `src/server/templates.rs` (after line 503, inside the existing structs block) + +Context: `RecipeMetadata` is defined at line 490. Add `NutritionData` before it, then add a `nutrition` field to `RecipeMetadata`. + +- [ ] **Step 1: Insert `NutritionData` struct and update `RecipeMetadata`** + +In `src/server/templates.rs`, add the `NutritionData` struct immediately before the `RecipeMetadata` struct definition, then add `nutrition: Option` as the last field of `RecipeMetadata`: + +```rust +// Add this struct before RecipeMetadata: +#[derive(Debug, Clone, Serialize)] +pub struct NutritionData { + pub calories: Option, + pub protein: Option, + pub fat: Option, + pub saturated_fat: Option, + pub carbohydrates: Option, + pub fiber: Option, + pub sugar: Option, + pub sodium: Option, + pub serving_size: Option, +} + +// Update RecipeMetadata to add the last field: +#[derive(Debug, Clone, Serialize)] +pub struct RecipeMetadata { + pub servings: Option, + pub time: Option, + pub difficulty: Option, + pub course: Option, + pub prep_time: Option, + pub cook_time: Option, + pub cuisine: Option, + pub diet: Option, + pub author: Option, + pub description: Option, + pub source: Option, + pub source_url: Option, + pub custom: Vec<(String, String)>, + pub nutrition: Option, // ← new field +} +``` + +- [ ] **Step 2: Compile-check** + +```bash +cargo check -p cookcli 2>&1 | head -30 +``` + +Expected: two struct instantiation errors in `builders.rs` (missing `nutrition` field) — this is correct, we fix them in Task 2. + +--- + +## Task 2: Implement `extract_nutrition` and wire it into both builders + +**Files:** +- Modify: `src/server/builders.rs` + +Context: `RecipeMetadata` is instantiated at two locations: +1. ~line 687 inside `build_recipe_template_inner` (recipe page) +2. ~line 937 inside `build_menu_template_inner` (menu page) + +Both share the same `map_filtered()` loop that builds `custom_metadata`. We add a free function `extract_nutrition` at the bottom of the file, call it from both sites, and exclude nutrition keys from `custom_metadata`. + +- [ ] **Step 1: Add `extract_nutrition` helper at the bottom of `builders.rs`** + +Append to the end of `src/server/builders.rs`: + +```rust +const NUTRITION_KEYS: &[&str] = &[ + "nutrition", + "calories", + "protein", + "fat", + "saturated fat", + "saturated_fat", + "carbohydrates", + "fiber", + "sugar", + "sodium", + "serving size", + "serving_size", +]; + +fn is_nutrition_key(key: &str) -> bool { + NUTRITION_KEYS.contains(&key) +} + +fn yaml_value_to_string(v: &serde_yaml::Value) -> Option { + if let Some(s) = v.as_str() { + Some(s.to_string()) + } else if let Some(n) = v.as_i64() { + Some(n.to_string()) + } else { + v.as_f64().map(|f| crate::util::format::format_number(f)) + } +} + +fn extract_nutrition(metadata: &cooklang::Metadata) -> Option { + // Try nested `nutrition:` mapping first (importer format) + if let Some(nutrition_val) = metadata.map.get("nutrition") { + if let Some(nutrition_map) = nutrition_val.as_mapping() { + let get = |key: &str| -> Option { + nutrition_map.get(key).and_then(yaml_value_to_string) + }; + + let data = NutritionData { + calories: get("calories"), + protein: get("protein"), + fat: get("fat"), + saturated_fat: get("saturated fat").or_else(|| get("saturated_fat")), + carbohydrates: get("carbohydrates"), + fiber: get("fiber"), + sugar: get("sugar"), + sodium: get("sodium"), + serving_size: get("serving size").or_else(|| get("serving_size")), + }; + + let has_data = data.calories.is_some() + || data.protein.is_some() + || data.fat.is_some() + || data.carbohydrates.is_some() + || data.fiber.is_some() + || data.sugar.is_some() + || data.sodium.is_some(); + + if has_data { + return Some(data); + } + } + } + + // Fall back to flat top-level keys (hand-authored format) + let get = |key: &str| -> Option { + metadata.map.get(key).and_then(yaml_value_to_string) + }; + + let data = NutritionData { + calories: get("calories"), + protein: get("protein"), + fat: get("fat"), + saturated_fat: get("saturated fat").or_else(|| get("saturated_fat")), + carbohydrates: get("carbohydrates"), + fiber: get("fiber"), + sugar: get("sugar"), + sodium: get("sodium"), + serving_size: get("serving size").or_else(|| get("serving_size")), + }; + + let has_data = data.calories.is_some() + || data.protein.is_some() + || data.fat.is_some() + || data.carbohydrates.is_some() + || data.fiber.is_some() + || data.sugar.is_some() + || data.sodium.is_some(); + + if has_data { Some(data) } else { None } +} +``` + +- [ ] **Step 2: Update the recipe builder (~line 676) to exclude nutrition keys and add the field** + +Find this block in `build_recipe_template_inner`: + +```rust + let mut custom_metadata = Vec::new(); + for (key, value) in recipe.metadata.map_filtered() { + if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { + if key_str.starts_with("source.") || key_str.starts_with("time.") { + continue; + } + + custom_metadata.push((key_str.to_string(), val_str.to_string())); + } + } + + Some(RecipeMetadata { + ... + custom: custom_metadata, + }) +``` + +Replace with: + +```rust + let nutrition = extract_nutrition(&recipe.metadata); + + let mut custom_metadata = Vec::new(); + for (key, value) in recipe.metadata.map_filtered() { + if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { + if key_str.starts_with("source.") + || key_str.starts_with("time.") + || is_nutrition_key(key_str) + { + continue; + } + + custom_metadata.push((key_str.to_string(), val_str.to_string())); + } + } + + Some(RecipeMetadata { + servings: get_field("servings"), + time: get_field("time"), + difficulty: get_field("difficulty"), + course: get_field("course"), + prep_time: get_field("prep time") + .or_else(|| get_field("prep_time")) + .or_else(|| get_field("preptime")) + .or_else(|| get_field("time.prep")), + cook_time: get_field("cook time") + .or_else(|| get_field("cook_time")) + .or_else(|| get_field("cooktime")) + .or_else(|| get_field("time.cook")), + cuisine: get_field("cuisine"), + diet: get_field("diet"), + author: get_field("author").or_else(|| get_field("source.author")), + description: get_field("description"), + source: get_field("source").or_else(|| get_field("source.name")), + source_url: get_field("source.url"), + custom: custom_metadata, + nutrition, + }) +``` + +- [ ] **Step 3: Update the menu builder (~line 930) to exclude nutrition keys and add the field** + +Find the analogous block in `build_menu_template_inner`: + +```rust + let mut custom_metadata = Vec::new(); + for (key, value) in recipe.metadata.map_filtered() { + if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { + custom_metadata.push((key_str.to_string(), val_str.to_string())); + } + } + + Some(RecipeMetadata { + ... + custom: custom_metadata, + }) +``` + +Replace with: + +```rust + let nutrition = extract_nutrition(&recipe.metadata); + + let mut custom_metadata = Vec::new(); + for (key, value) in recipe.metadata.map_filtered() { + if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { + if is_nutrition_key(key_str) { + continue; + } + custom_metadata.push((key_str.to_string(), val_str.to_string())); + } + } + + Some(RecipeMetadata { + servings: get_field("servings"), + time: get_field("time"), + difficulty: get_field("difficulty"), + course: get_field("course"), + prep_time: get_field("prep time") + .or_else(|| get_field("prep_time")) + .or_else(|| get_field("preptime")), + cook_time: get_field("cook time") + .or_else(|| get_field("cook_time")) + .or_else(|| get_field("cooktime")), + cuisine: get_field("cuisine"), + diet: get_field("diet"), + author: get_field("author").or_else(|| get_field("source.author")), + description: get_field("description"), + source: get_field("source").or_else(|| get_field("source.name")), + source_url: get_field("source.url"), + custom: custom_metadata, + nutrition, + }) +``` + +- [ ] **Step 4: Compile-check** + +```bash +cargo check -p cookcli 2>&1 | head -30 +``` + +Expected: clean compile (no errors, possibly clippy-style warnings about unused imports — these are fine for now). + +- [ ] **Step 5: Commit** + +```bash +git add src/server/templates.rs src/server/builders.rs +git commit -m "feat: extract nutrition data from recipe metadata" +``` + +--- + +## Task 3: Add translation keys to all 7 locale files + +**Files:** +- Modify: `locales/en-US/recipes.ftl` +- Modify: `locales/fr-FR/recipes.ftl` +- Modify: `locales/de-DE/recipes.ftl` +- Modify: `locales/es-ES/recipes.ftl` +- Modify: `locales/eu-ES/recipes.ftl` +- Modify: `locales/nl-NL/recipes.ftl` +- Modify: `locales/sv-SE/recipes.ftl` + +- [ ] **Step 1: Add keys to `locales/en-US/recipes.ftl`** + +Append to the `# Recipe Metadata` section: + +```fluent +meta-nutrition = Nutrition +meta-calories = Calories +meta-protein = Protein +meta-fat = Fat +meta-saturated-fat = Saturated Fat +meta-carbohydrates = Carbohydrates +meta-fiber = Fiber +meta-sugar = Sugar +meta-sodium = Sodium +meta-serving-size = Serving Size +``` + +- [ ] **Step 2: Add keys to `locales/fr-FR/recipes.ftl`** + +```fluent +meta-nutrition = Valeurs nutritionnelles +meta-calories = Calories +meta-protein = Protéines +meta-fat = Lipides +meta-saturated-fat = Acides gras saturés +meta-carbohydrates = Glucides +meta-fiber = Fibres +meta-sugar = Sucres +meta-sodium = Sodium +meta-serving-size = Par portion +``` + +- [ ] **Step 3: Add keys to `locales/de-DE/recipes.ftl`** + +```fluent +meta-nutrition = Nährwerte +meta-calories = Kalorien +meta-protein = Eiweiß +meta-fat = Fett +meta-saturated-fat = Gesättigte Fettsäuren +meta-carbohydrates = Kohlenhydrate +meta-fiber = Ballaststoffe +meta-sugar = Zucker +meta-sodium = Natrium +meta-serving-size = Portionsgröße +``` + +- [ ] **Step 4: Add keys to `locales/es-ES/recipes.ftl`** + +```fluent +meta-nutrition = Información nutricional +meta-calories = Calorías +meta-protein = Proteínas +meta-fat = Grasas +meta-saturated-fat = Grasas saturadas +meta-carbohydrates = Carbohidratos +meta-fiber = Fibra +meta-sugar = Azúcares +meta-sodium = Sodio +meta-serving-size = Tamaño de porción +``` + +- [ ] **Step 5: Add keys to `locales/eu-ES/recipes.ftl`** + +```fluent +meta-nutrition = Elikadura balioak +meta-calories = Kaloriak +meta-protein = Proteinak +meta-fat = Koipeak +meta-saturated-fat = Gantz aseak +meta-carbohydrates = Karbohidratoak +meta-fiber = Zuntzak +meta-sugar = Azukreak +meta-sodium = Sodioa +meta-serving-size = Zerbitzatu tamaina +``` + +- [ ] **Step 6: Add keys to `locales/nl-NL/recipes.ftl`** + +```fluent +meta-nutrition = Voedingswaarden +meta-calories = Calorieën +meta-protein = Eiwitten +meta-fat = Vetten +meta-saturated-fat = Verzadigde vetten +meta-carbohydrates = Koolhydraten +meta-fiber = Vezels +meta-sugar = Suikers +meta-sodium = Natrium +meta-serving-size = Portiegrootte +``` + +- [ ] **Step 7: Add keys to `locales/sv-SE/recipes.ftl`** + +```fluent +meta-nutrition = Näringsvärden +meta-calories = Kalorier +meta-protein = Protein +meta-fat = Fett +meta-saturated-fat = Mättat fett +meta-carbohydrates = Kolhydrater +meta-fiber = Fibrer +meta-sugar = Socker +meta-sodium = Natrium +meta-serving-size = Portionsstorlek +``` + +- [ ] **Step 8: Commit** + +```bash +git add locales/ +git commit -m "feat: add nutrition i18n keys to all 7 locales" +``` + +--- + +## Task 4: Add collapsible nutrition section to `recipe.html` + +**Files:** +- Modify: `templates/recipe.html` + +Context: The insertion point is inside the `{% when Some with (metadata) %}` block, after the closing `` of `#metadata-container` (around line 195) and before `{% when None %}`. + +Tailwind v3.4+ `open:` variant works on elements inside an open `
`: `details[open] .open\:rotate-180 { transform: rotate(180deg); }`. + +- [ ] **Step 1: Add the collapsible nutrition block** + +In `templates/recipe.html`, find the closing of the `#metadata-container` div and the next `{% when None %}` after the metadata block. The section looks like: + +```html + + {% for (key, value) in metadata.custom %} + + {% endfor %} + + + {% when None %} + {% endmatch %} +``` + +Insert the following between `` and `{% when None %}`: + +```html + + {% for (key, value) in metadata.custom %} + + {% endfor %} + + + {% match metadata.nutrition %} + {% when Some with (nutrition) %} +
+ + 🔬 {{ tr.t("meta-nutrition") }} + + + + +
+
+ {% match nutrition.calories %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-calories") }}
+
+ {% when None %} + {% endmatch %} + {% match nutrition.protein %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-protein") }}
+
+ {% when None %} + {% endmatch %} + {% match nutrition.fat %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-fat") }}
+
+ {% when None %} + {% endmatch %} + {% match nutrition.carbohydrates %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-carbohydrates") }}
+
+ {% when None %} + {% endmatch %} +
+
+ {% match nutrition.fiber %} + {% when Some with (val) %} + {{ tr.t("meta-fiber") }}: {{ val }} + {% when None %} + {% endmatch %} + {% match nutrition.sugar %} + {% when Some with (val) %} + {{ tr.t("meta-sugar") }}: {{ val }} + {% when None %} + {% endmatch %} + {% match nutrition.sodium %} + {% when Some with (val) %} + {{ tr.t("meta-sodium") }}: {{ val }} + {% when None %} + {% endmatch %} + {% match nutrition.saturated_fat %} + {% when Some with (val) %} + {{ tr.t("meta-saturated-fat") }}: {{ val }} + {% when None %} + {% endmatch %} +
+ {% match nutrition.serving_size %} + {% when Some with (val) %} +

{{ tr.t("meta-serving-size") }}: {{ val }}

+ {% when None %} + {% endmatch %} +
+
+ {% when None %} + {% endmatch %} + + {% when None %} + {% endmatch %} +``` + +- [ ] **Step 2: Build CSS (Tailwind needs to scan the new classes)** + +```bash +cd /path/to/cookcli && npm run build-css +``` + +Expected: `static/css/output.css` updated with teal/blue/yellow/orange/green/pink/amber/gray utility classes used in the template. + +- [ ] **Step 3: Full build** + +```bash +cargo build -p cookcli 2>&1 | head -30 +``` + +Expected: clean compile. + +- [ ] **Step 4: Commit** + +```bash +git add templates/recipe.html static/css/output.css +git commit -m "feat: add collapsible nutrition section to recipe page" +``` + +--- + +## Task 5: Manual verification + +**Files:** none — testing only + +The `seed/Risotto.cook` file already contains a full nested `nutrition:` block with all fields (calories, fat, saturated fat, carbohydrates, sugar, protein, fiber, sodium, serving size). Use it to verify everything works end-to-end. + +- [ ] **Step 1: Start the dev server** + +```bash +cargo run -- server ./seed +``` + +Open a browser at `http://localhost:9080`. + +- [ ] **Step 2: Navigate to the Risotto recipe** + +Click on "Classic Risotto alla Milanese" from the recipe list. + +Expected: +- A `🔬 Nutrition` collapsible bar appears between the metadata pills row and the ingredients/steps grid. +- The bar is collapsed by default (no nutrition details visible). + +- [ ] **Step 3: Expand the nutrition section** + +Click the `🔬 Nutrition` summary row. + +Expected: +- Section expands to show 4 primary stat boxes: Calories (887 kcal), Protein (37.3 g), Fat (44.6 g), Carbohydrates (85.5 g) +- Secondary pills: Fiber (7.3 g), Sugar (17.9 g), Sodium (5.3 g), Saturated Fat (11.7 g) +- Serving size line: "Serving Size: 494" +- The chevron rotates 180° to point up + +- [ ] **Step 4: Verify nutrition keys absent from custom metadata** + +Check that no nutrition-related pills (`calories: …`, `fat: …`, etc.) appear in the grey metadata pill row above the nutrition section. + +- [ ] **Step 5: Verify a recipe without nutrition shows no section** + +Open a recipe that has no nutrition metadata (e.g., `Neapolitan Pizza.cook` or `lamb-chops.cook`). + +Expected: no `🔬 Nutrition` section visible at all. + +- [ ] **Step 6: Final quality checks** + +```bash +cargo fmt +cargo clippy -- -D warnings +cargo test +``` + +Expected: no formatting changes, no clippy warnings, 0 tests run (project has no automated tests yet). + +- [ ] **Step 7: Final commit** + +```bash +git add -p # stage only if anything changed from fmt +git commit -m "chore: fmt and clippy cleanup for nutrition feature" +``` + +If `cargo fmt` made no changes and clippy is clean, skip this commit. diff --git a/docs/superpowers/specs/2026-06-05-nutrition-display-design.md b/docs/superpowers/specs/2026-06-05-nutrition-display-design.md new file mode 100644 index 0000000..bc355d1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-nutrition-display-design.md @@ -0,0 +1,129 @@ +# Nutrition Info Display — Design Spec + +**Date:** 2026-06-05 +**Status:** Approved + +## Goal + +Display nutrition information stored in `.cook` recipe metadata in the web UI, when available. Two storage formats exist: flat top-level keys (hand-authored) and a nested `nutrition:` mapping (from imported recipes). + +## Data formats supported + +**Flat keys** (hand-authored): +``` +>> calories: 350 +>> protein: 25g +>> fat: 12g +>> carbohydrates: 40g +>> fiber: 5g +>> sugar: 8g +>> sodium: 480mg +``` + +**Nested mapping** (importer output): +``` +>> nutrition: +>> calories: 350 calories +>> fat: 18 grams fat +>> protein: 25g +>> carbohydrates: 40g +>> fiber: 5g +>> sugar: 8g +>> sodium: 480mg +>> serving size: 1 cup +``` + +## Architecture + +### 1. `src/server/templates.rs` — new `NutritionData` struct + +Add a new struct and a field to `RecipeMetadata`: + +```rust +pub struct NutritionData { + pub calories: Option, + pub protein: Option, + pub fat: Option, + pub saturated_fat: Option, + pub carbohydrates: Option, + pub fiber: Option, + pub sugar: Option, + pub sodium: Option, + pub serving_size: Option, +} + +// RecipeMetadata gains: +pub nutrition: Option, +``` + +### 2. `src/server/builders.rs` — extract nutrition + +Before building `custom_metadata`, extract nutrition from both formats: + +1. Check if `nutrition` key exists as a YAML mapping → read sub-keys +2. Otherwise look for flat top-level keys (`calories`, `protein`, `fat`, `saturated fat` / `saturated_fat`, `carbohydrates`, `fiber`, `sugar`, `sodium`, `serving size` / `serving_size`) +3. A `NutritionData` is `None` if no fields are found +4. Exclude all nutrition-related keys from the `custom` vec to avoid duplication + +Known flat key names to exclude from custom: +`calories`, `protein`, `fat`, `saturated fat`, `saturated_fat`, `carbohydrates`, `fiber`, `sugar`, `sodium`, `serving size`, `serving_size` + +The same extraction logic also applies to the second `RecipeMetadata` builder at line ~937 (menu template). + +### 3. `templates/recipe.html` — collapsible section + +Position: between the metadata pills container (`#metadata-container`) and the ingredients/steps grid. + +Render only when `nutrition` is `Some`. Use a native HTML `
`/`` element (no JS required). Style with Tailwind to match the existing card aesthetic. + +Visual design: +- Summary line: `🔬 Nutrition` (label via `tr.t("meta-nutrition")`) with a chevron indicator +- Expanded content: a row of pill-like stat boxes for each present field +- Primary fields (larger): Calories, Protein, Fat, Carbs +- Secondary fields (smaller): Fiber, Sugar, Sodium, Saturated Fat, Serving size +- Label/value layout inside each pill; all labels use `tr.t("meta-")` keys + +### 4. `locales/*/recipes.ftl` — translation keys + +Add the following keys to all 7 locale files (en-US, de-DE, es-ES, eu-ES, fr-FR, nl-NL, sv-SE): + +``` +meta-nutrition = Nutrition +meta-calories = Calories +meta-protein = Protein +meta-fat = Fat +meta-saturated-fat = Saturated Fat +meta-carbohydrates = Carbohydrates +meta-fiber = Fiber +meta-sugar = Sugar +meta-sodium = Sodium +meta-serving-size = Serving Size +``` + +Translations per locale (best-effort; native speakers should review): + +| Key | fr-FR | de-DE | es-ES | eu-ES | nl-NL | sv-SE | +|-----|-------|-------|-------|-------|-------|-------| +| meta-nutrition | Valeurs nutritionnelles | Nährwerte | Información nutricional | Elikadura balioak | Voedingswaarden | Näringsvärden | +| meta-calories | Calories | Kalorien | Calorías | Kaloriak | Calorieën | Kalorier | +| meta-protein | Protéines | Eiweiß | Proteínas | Proteinak | Eiwitten | Protein | +| meta-fat | Lipides | Fett | Grasas | Koipeak | Vetten | Fett | +| meta-saturated-fat | Acides gras saturés | Gesättigte Fettsäuren | Grasas saturadas | Gantz aseak | Verzadigde vetten | Mättat fett | +| meta-carbohydrates | Glucides | Kohlenhydrate | Carbohidratos | Karbohidratoak | Koolhydraten | Kolhydrater | +| meta-fiber | Fibres | Ballaststoffe | Fibra | Zuntzak | Vezels | Fibrer | +| meta-sugar | Sucres | Zucker | Azúcares | Azukreak | Suikers | Socker | +| meta-sodium | Sodium | Natrium | Sodio | Sodioa | Natrium | Natrium | +| meta-serving-size | Par portion | Portionsgröße | Tamaño de porción | Zerbitzatu tamaina | Portiegrootte | Portionsstorlek | + +## Scope + +- No changes to `cooklang-rs` — nutrition is not a `StdKey`; we read it from the raw metadata map +- No changes to the CLI `recipe` command output — this is UI-only +- No per-serving scaling of nutrition values — values are displayed as stored +- Both builder instances in `builders.rs` updated for consistency + +## Out of scope + +- Nutrition per-serving math when scaling +- Editing nutrition from the UI +- CLI text/JSON output of nutrition diff --git a/locales/de-DE/recipes.ftl b/locales/de-DE/recipes.ftl index c80a1f2..603ace4 100644 --- a/locales/de-DE/recipes.ftl +++ b/locales/de-DE/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Gesamtzeit meta-servings = Portionen meta-difficulty = Schwierigkeit meta-description = Beschreibung +meta-nutrition = Nährwerte +meta-calories = Kalorien +meta-protein = Eiweiß +meta-fat = Fett +meta-saturated-fat = Gesättigte Fettsäuren +meta-carbohydrates = Kohlenhydrate +meta-fiber = Ballaststoffe +meta-sugar = Zucker +meta-sodium = Natrium +meta-serving-size = Portionsgröße # Recipe Types recipe-type-menu = Menü diff --git a/locales/en-US/recipes.ftl b/locales/en-US/recipes.ftl index 1b4efd5..e46630e 100644 --- a/locales/en-US/recipes.ftl +++ b/locales/en-US/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Total Time meta-servings = Servings meta-difficulty = Difficulty meta-description = Description +meta-nutrition = Nutrition +meta-calories = Calories +meta-protein = Protein +meta-fat = Fat +meta-saturated-fat = Saturated Fat +meta-carbohydrates = Carbohydrates +meta-fiber = Fiber +meta-sugar = Sugar +meta-sodium = Sodium +meta-serving-size = Serving Size # Recipe Types recipe-type-menu = Menu diff --git a/locales/es-ES/recipes.ftl b/locales/es-ES/recipes.ftl index 171455b..46e70d0 100644 --- a/locales/es-ES/recipes.ftl +++ b/locales/es-ES/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Tiempo Total meta-servings = Porciones meta-difficulty = Dificultad meta-description = Descripción +meta-nutrition = Información nutricional +meta-calories = Calorías +meta-protein = Proteínas +meta-fat = Grasas +meta-saturated-fat = Grasas saturadas +meta-carbohydrates = Carbohidratos +meta-fiber = Fibra +meta-sugar = Azúcares +meta-sodium = Sodio +meta-serving-size = Tamaño de porción # Recipe Types recipe-type-menu = Menú diff --git a/locales/eu-ES/recipes.ftl b/locales/eu-ES/recipes.ftl index 688acca..bc4655a 100644 --- a/locales/eu-ES/recipes.ftl +++ b/locales/eu-ES/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Denbora guztira meta-servings = Anoak meta-difficulty = Zailtasuna meta-description = Deskribapena +meta-nutrition = Elikadura balioak +meta-calories = Kaloriak +meta-protein = Proteinak +meta-fat = Koipeak +meta-saturated-fat = Gantz aseak +meta-carbohydrates = Karbohidratoak +meta-fiber = Zuntzak +meta-sugar = Azukreak +meta-sodium = Sodioa +meta-serving-size = Zerbitzatu tamaina # Recipe Types recipe-type-menu = Menua diff --git a/locales/fr-FR/recipes.ftl b/locales/fr-FR/recipes.ftl index 899ef28..4df1b81 100644 --- a/locales/fr-FR/recipes.ftl +++ b/locales/fr-FR/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Temps Total meta-servings = Portions meta-difficulty = Difficulté meta-description = Description +meta-nutrition = Valeurs nutritionnelles +meta-calories = Calories +meta-protein = Protéines +meta-fat = Lipides +meta-saturated-fat = Acides gras saturés +meta-carbohydrates = Glucides +meta-fiber = Fibres +meta-sugar = Sucres +meta-sodium = Sodium +meta-serving-size = Par portion # Recipe Types recipe-type-menu = Menu diff --git a/locales/nl-NL/recipes.ftl b/locales/nl-NL/recipes.ftl index b6c84fd..c5b4ef8 100644 --- a/locales/nl-NL/recipes.ftl +++ b/locales/nl-NL/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Totale tijd meta-servings = Porties meta-difficulty = Moeilijkheidsgraad meta-description = Beschrijving +meta-nutrition = Voedingswaarden +meta-calories = Calorieën +meta-protein = Eiwitten +meta-fat = Vetten +meta-saturated-fat = Verzadigde vetten +meta-carbohydrates = Koolhydraten +meta-fiber = Vezels +meta-sugar = Suikers +meta-sodium = Natrium +meta-serving-size = Portiegrootte # Recipe Types recipe-type-menu = Menu diff --git a/locales/sv-SE/recipes.ftl b/locales/sv-SE/recipes.ftl index 77256c5..a8d63b7 100644 --- a/locales/sv-SE/recipes.ftl +++ b/locales/sv-SE/recipes.ftl @@ -36,6 +36,16 @@ meta-total-time = Total tid meta-servings = Portioner meta-difficulty = Svårighet meta-description = Beskrivning +meta-nutrition = Näringsvärden +meta-calories = Kalorier +meta-protein = Protein +meta-fat = Fett +meta-saturated-fat = Mättat fett +meta-carbohydrates = Kolhydrater +meta-fiber = Fibrer +meta-sugar = Socker +meta-sodium = Natrium +meta-serving-size = Portionsstorlek # Recipe Types recipe-type-menu = Meny diff --git a/src/server/builders.rs b/src/server/builders.rs index aaed901..43c9a9a 100644 --- a/src/server/builders.rs +++ b/src/server/builders.rs @@ -692,10 +692,15 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result) -> Result Optio } } } + +const NUTRITION_KEYS: &[&str] = &[ + "nutrition", + "calories", + "protein", + "fat", + "saturated fat", + "saturated_fat", + "carbohydrates", + "fiber", + "sugar", + "sodium", + "serving size", + "serving_size", +]; + +fn is_nutrition_key(key: &str) -> bool { + NUTRITION_KEYS.contains(&key) +} + +fn yaml_value_to_string(v: &serde_yaml::Value) -> Option { + if let Some(s) = v.as_str() { + Some(s.to_string()) + } else if let Some(n) = v.as_i64() { + Some(n.to_string()) + } else { + v.as_f64().map(crate::util::format::format_number) + } +} + +fn extract_nutrition(metadata: &cooklang::Metadata) -> Option { + // Try nested `nutrition:` mapping first (importer format) + if let Some(nutrition_val) = metadata.map.get("nutrition") { + if let Some(nutrition_map) = nutrition_val.as_mapping() { + let get = |key: &str| -> Option { + nutrition_map.get(key).and_then(yaml_value_to_string) + }; + + let data = NutritionData { + calories: get("calories"), + protein: get("protein"), + fat: get("fat"), + saturated_fat: get("saturated fat").or_else(|| get("saturated_fat")), + carbohydrates: get("carbohydrates"), + fiber: get("fiber"), + sugar: get("sugar"), + sodium: get("sodium"), + serving_size: get("serving size").or_else(|| get("serving_size")), + }; + + let has_data = data.calories.is_some() + || data.protein.is_some() + || data.fat.is_some() + || data.saturated_fat.is_some() + || data.carbohydrates.is_some() + || data.fiber.is_some() + || data.sugar.is_some() + || data.sodium.is_some() + || data.serving_size.is_some(); + + if has_data { + return Some(data); + } + } + } + + // Fall back to flat top-level keys (hand-authored format) + let get = + |key: &str| -> Option { metadata.map.get(key).and_then(yaml_value_to_string) }; + + let data = NutritionData { + calories: get("calories"), + protein: get("protein"), + fat: get("fat"), + saturated_fat: get("saturated fat").or_else(|| get("saturated_fat")), + carbohydrates: get("carbohydrates"), + fiber: get("fiber"), + sugar: get("sugar"), + sodium: get("sodium"), + serving_size: get("serving size").or_else(|| get("serving_size")), + }; + + let has_data = data.calories.is_some() + || data.protein.is_some() + || data.fat.is_some() + || data.saturated_fat.is_some() + || data.carbohydrates.is_some() + || data.fiber.is_some() + || data.sugar.is_some() + || data.sodium.is_some() + || data.serving_size.is_some(); + + if has_data { + Some(data) + } else { + None + } +} diff --git a/src/server/templates.rs b/src/server/templates.rs index b4af274..fa1eec7 100644 --- a/src/server/templates.rs +++ b/src/server/templates.rs @@ -497,6 +497,19 @@ pub struct RecipeData { pub metadata: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct NutritionData { + pub calories: Option, + pub protein: Option, + pub fat: Option, + pub saturated_fat: Option, + pub carbohydrates: Option, + pub fiber: Option, + pub sugar: Option, + pub sodium: Option, + pub serving_size: Option, +} + #[derive(Debug, Clone, Serialize)] pub struct RecipeMetadata { pub servings: Option, @@ -512,6 +525,7 @@ pub struct RecipeMetadata { pub source: Option, pub source_url: Option, pub custom: Vec<(String, String)>, + pub nutrition: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/templates/recipe.html b/templates/recipe.html index 2cbb7f7..befdf32 100644 --- a/templates/recipe.html +++ b/templates/recipe.html @@ -193,6 +193,82 @@

+ + 🔬 {{ tr.t("meta-nutrition") }} + + + + +
+
+ {% match nutrition.calories %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-calories") }}
+
+ {% when None %} + {% endmatch %} + {% match nutrition.protein %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-protein") }}
+
+ {% when None %} + {% endmatch %} + {% match nutrition.fat %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-fat") }}
+
+ {% when None %} + {% endmatch %} + {% match nutrition.carbohydrates %} + {% when Some with (val) %} +
+
{{ val }}
+
{{ tr.t("meta-carbohydrates") }}
+
+ {% when None %} + {% endmatch %} +
+
+ {% match nutrition.fiber %} + {% when Some with (val) %} + {{ tr.t("meta-fiber") }}: {{ val }} + {% when None %} + {% endmatch %} + {% match nutrition.sugar %} + {% when Some with (val) %} + {{ tr.t("meta-sugar") }}: {{ val }} + {% when None %} + {% endmatch %} + {% match nutrition.sodium %} + {% when Some with (val) %} + {{ tr.t("meta-sodium") }}: {{ val }} + {% when None %} + {% endmatch %} + {% match nutrition.saturated_fat %} + {% when Some with (val) %} + {{ tr.t("meta-saturated-fat") }}: {{ val }} + {% when None %} + {% endmatch %} +
+ {% match nutrition.serving_size %} + {% when Some with (val) %} +

{{ tr.t("meta-serving-size") }}: {{ val }}

+ {% when None %} + {% endmatch %} +
+

+ {% when None %} + {% endmatch %} + {% when None %} {% endmatch %}