From 371341bfea6a19b0d0d3581ea9ac52d7af5677b5 Mon Sep 17 00:00:00 2001 From: Yannick LANG Date: Mon, 24 Aug 2026 19:35:00 +0200 Subject: [PATCH 1/3] feat(server): display custom list metadata (e.g. nutrition) on recipe pages Any non-standard YAML frontmatter key whose value is a list (e.g. `nutrition:`) is now shown as its own line below the tags on the recipe and menu pages, one line per family. Nutrition entries get a matching icon (Tabler Icons, MIT) based on keyword: kcal/energy, protein, lipid/fat, sugar, fiber. The recipe list page shows a compact kcal badge next to tags when `nutrition:` is present. Co-Authored-By: Claude Sonnet 5 --- docs/server.md | 28 +++++++++ src/web/builders.rs | 14 ++++- src/web/mod.rs | 1 + src/web/nutrition.rs | 131 +++++++++++++++++++++++++++++++++++++++++ src/web/templates.rs | 21 +++++++ templates/menu.html | 21 +++++++ templates/recipe.html | 21 +++++++ templates/recipes.html | 10 +++- 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/web/nutrition.rs diff --git a/docs/server.md b/docs/server.md index 43575b8a..16cbb1af 100644 --- a/docs/server.md +++ b/docs/server.md @@ -50,3 +50,31 @@ cook server --host - The web interface supports recipe browsing, scaling, search, and shopping list management - The UI language is negotiated per request from the browser's `Accept-Language` header — each visitor sees the interface in their own language (supported: `en-US`, `de-DE`, `nl-NL`, `fr-FR`, `es-ES`, `eu-ES`, `sv-SE`). For static sites, see the `--lang` flag of [`cook build web`](build.md#localization). - Mobile-friendly responsive layout + +## Custom Metadata Families (e.g. Nutrition) + +Beyond the [standard Cooklang metadata keys](https://cooklang.org/docs/spec/#canonical-metadata) (`servings`, `time`, `course`, `author`, ...), any YAML frontmatter key whose value is a **list** is shown on the recipe page as its own line below the tags, grouped by key — one line per family. + +```yaml +--- +tags: + - vegan + - gluten-free +nutrition: + - 258%kcal + - 4.2%g of proteins + - 4.8%g of lipids + - 39.4%g of sugars + - 5.3%g of fibers +allergens: + - gluten + - tree nuts +--- +``` + +- Each entry is written as `value%unit` (e.g. `258%kcal`); the `%` is replaced with a space when displayed (`258 kcal`). Entries without a `%` are shown as-is. +- The family label is the YAML key, capitalized (`nutrition` → `Nutrition`). +- `tags` is never treated as a custom family — it keeps its own dedicated row above. +- Icons are only attached to the `nutrition` family, matched by keyword in the unit text: `kcal`/`cal`/`energy` (flame), `protein` (meat), `lipid`/`fat` (droplet), `sugar` (candy), `fiber`/`fibre` (wheat). Any other family, or an unrecognized nutrition unit, renders as a plain bullet with no icon. +- On the recipe **list** page, only the calorie entry from `nutrition` (the item containing `kcal`) is shown, as a compact badge next to the tags — the other custom families are only shown on the recipe detail page, to keep list cards compact. +- This works for any list-valued key, not just `nutrition` — e.g. `allergens:` above renders as its own "Allergens" line automatically, with no code changes required. diff --git a/src/web/builders.rs b/src/web/builders.rs index 494cb534..3ff83bae 100644 --- a/src/web/builders.rs +++ b/src/web/builders.rs @@ -68,7 +68,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result) -> Result) -> Result) -> Result) -> Result"##; +const ICON_MEAT: &str = r##""##; +const ICON_DROPLET: &str = r##""##; +const ICON_CANDY: &str = r##""##; +const ICON_WHEAT: &str = r##""##; + +/// Picks an icon for one `nutrition:` entry based on keywords in its text +/// (e.g. `"258 kcal"`, `"4.2 g of proteins"`). Returns `None` for anything +/// unrecognized, which renders as a plain bullet. +fn nutrition_icon(text: &str) -> Option<&'static str> { + let lower = text.to_lowercase(); + if lower.contains("kcal") || lower.contains("cal") || lower.contains("energy") { + Some(ICON_FLAME) + } else if lower.contains("protein") { + Some(ICON_MEAT) + } else if lower.contains("lipid") || lower.contains("fat") { + Some(ICON_DROPLET) + } else if lower.contains("sugar") { + Some(ICON_CANDY) + } else if lower.contains("fiber") || lower.contains("fibre") { + Some(ICON_WHEAT) + } else { + None + } +} + +/// Turns a raw entry value like `"258%kcal"` into display text `"258 kcal"`. +/// Entries without the `%` separator (used by non-nutrition families) are +/// left untouched. +fn format_list_entry(raw: &str) -> String { + match raw.split_once('%') { + Some((value, unit)) => format!("{} {}", value.trim(), unit.trim()), + None => raw.trim().to_string(), + } +} + +fn capitalize(label: &str) -> String { + let label = label.replace(['_', '-'], " "); + let mut chars = label.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => label, + } +} + +/// Builds the "one line per family" metadata view from every non-standard, +/// list-valued key in a recipe's metadata (`tags` is always excluded — it has +/// its own row). Icons are only attached to the `nutrition` family. +pub fn build_custom_list_families<'a>( + map_filtered: impl Iterator, +) -> Vec { + let mut families = Vec::new(); + for (key, value) in map_filtered { + let Some(key_str) = key.as_str() else { + continue; + }; + if key_str.eq_ignore_ascii_case("tags") || key_str.eq_ignore_ascii_case("tag") { + continue; + } + let Some(seq) = value.as_sequence() else { + continue; + }; + let is_nutrition = key_str.eq_ignore_ascii_case("nutrition"); + let items: Vec = seq + .iter() + .filter_map(|v| v.as_str()) + .map(|raw| { + let text = format_list_entry(raw); + let icon = if is_nutrition { + nutrition_icon(&text).map(str::to_string) + } else { + None + }; + MetaListItem { icon, text } + }) + .collect(); + if !items.is_empty() { + families.push(MetaListFamily { + label: capitalize(key_str), + items, + }); + } + } + families +} + +/// Extracts the calorie entry (`"258%kcal"` -> `"258 kcal"`) from a recipe's +/// `nutrition:` list, for the compact badge shown on the recipe list page. +/// Uses the lightweight [`cooklang_find::Metadata`] (frontmatter only, no +/// full recipe parse) so listing a directory stays cheap. +pub fn extract_nutrition_kcal(metadata: &cooklang_find::Metadata) -> Option { + let seq = metadata.get("nutrition")?.as_sequence()?; + seq.iter().find_map(|v| { + let raw = v.as_str()?; + if raw.to_lowercase().contains("kcal") { + Some(format_list_entry(raw)) + } else { + None + } + }) +} diff --git a/src/web/templates.rs b/src/web/templates.rs index c7ac3f98..15f10890 100644 --- a/src/web/templates.rs +++ b/src/web/templates.rs @@ -589,6 +589,9 @@ pub struct RecipeItem { pub count: Option, pub description: Option, pub tags: Vec, + /// Calorie entry from a `nutrition:` metadata list (e.g. `"258 kcal"`), + /// shown as a compact badge next to the tags. + pub nutrition_kcal: Option, pub image_path: Option, pub is_menu: bool, pub modified_at: Option, @@ -616,6 +619,24 @@ pub struct RecipeMetadata { pub source: Option, pub source_url: Option, pub custom: Vec<(String, String)>, + /// Non-standard metadata keys whose value is a YAML list (e.g. `nutrition:`), + /// one entry per family, rendered as one line each below the tags. + pub custom_lists: Vec, +} + +/// One family line of list-valued custom metadata (e.g. `nutrition`). +#[derive(Debug, Clone, Serialize)] +pub struct MetaListFamily { + pub label: String, + pub items: Vec, +} + +/// One entry within a [`MetaListFamily`] (e.g. `"258 kcal"`). +#[derive(Debug, Clone, Serialize)] +pub struct MetaListItem { + /// Inline SVG markup (Tabler Icons, MIT), or `None` for a plain bullet. + pub icon: Option, + pub text: String, } #[derive(Debug, Clone, Serialize)] diff --git a/templates/menu.html b/templates/menu.html index d396ee7e..0262b07f 100644 --- a/templates/menu.html +++ b/templates/menu.html @@ -123,6 +123,27 @@

+ {% for family in metadata.custom_lists %} +
+ {{ family.label }} + {% for item in family.items %} + + {% match item.icon %} + {% when Some with (icon) %} + {{ icon|safe }} + {% when None %} + {% endmatch %} + {{ item.text }} + + {% endfor %} +
+ {% endfor %} + + {% endif %} + {% when None %} {% endmatch %} diff --git a/templates/recipe.html b/templates/recipe.html index 476748dd..1bd24133 100644 --- a/templates/recipe.html +++ b/templates/recipe.html @@ -193,6 +193,27 @@

+ {% for family in metadata.custom_lists %} +
+ {{ family.label }} + {% for item in family.items %} + + {% match item.icon %} + {% when Some with (icon) %} + {{ icon|safe }} + {% when None %} + {% endmatch %} + {{ item.text }} + + {% endfor %} +
+ {% endfor %} + + {% endif %} + {% when None %} {% endmatch %} diff --git a/templates/recipes.html b/templates/recipes.html index f20782ad..fc07acc1 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -110,7 +110,7 @@

{{ description }}

{% when None %} {% endmatch %} - {% if !item.tags.is_empty() %} + {% if !item.tags.is_empty() || item.nutrition_kcal.is_some() %}
{% for tag in item.tags.iter().take(3) %} {{ tag }} @@ -118,6 +118,14 @@

{% if item.tags.len() > 3 %} +{{ item.tags.len() - 3 }} {% endif %} + {% match item.nutrition_kcal %} + {% when Some with (kcal) %} + + + {{ kcal }} + + {% when None %} + {% endmatch %}

{% endif %} From eeca3ecedce8d43fb39b784370a1cb6a0e647e24 Mon Sep 17 00:00:00 2001 From: Yannick LANG Date: Mon, 24 Aug 2026 19:57:00 +0200 Subject: [PATCH 2/3] feat(server): support mapping-form nutrition/file metadata, i18n, dot-hiding Extends the custom metadata family display added in the previous commit: - Supports a mapping form (`nutrition: {kcal: 234, proteins: 9.8, ...}`) alongside the original list form, with per-field unit inference (kcal -> kcal, everything else -> g) and 3 new nutrient fields (saturated-fat, carbohydrates, salt) plus matching icons. - Nutrient names and a new `file`/`meta` family (created-by/created-at/ modified-by/modified-at, with person/calendar/pencil/history icons) are translated into the viewer's UI language via the existing Fluent i18n system, across all 7 supported locales. - A YAML key prefixed with "." is hidden from the recipe page, at either the family level (`.file:`) or a single mapping entry (`.lipids:`). - Refactors the per-family rendering into src/web/family_renderers/ (mod.rs + generic.rs + nutrition.rs + file.rs): a small FamilyRenderer trait with a generic fallback, and an explicit renderer_for() registry so adding a future specific renderer only touches one match arm. Co-Authored-By: Claude Sonnet 5 --- docs/server.md | 44 ++++-- locales/de-DE/recipes.ftl | 13 ++ locales/en-US/recipes.ftl | 13 ++ locales/es-ES/recipes.ftl | 13 ++ locales/eu-ES/recipes.ftl | 13 ++ locales/fr-FR/recipes.ftl | 13 ++ locales/nl-NL/recipes.ftl | 13 ++ locales/sv-SE/recipes.ftl | 13 ++ src/web/builders.rs | 22 ++- src/web/family_renderers/file.rs | 75 +++++++++ src/web/family_renderers/generic.rs | 31 ++++ src/web/family_renderers/mod.rs | 220 ++++++++++++++++++++++++++ src/web/family_renderers/nutrition.rs | 144 +++++++++++++++++ src/web/mod.rs | 2 +- src/web/nutrition.rs | 131 --------------- templates/menu.html | 26 +-- templates/recipe.html | 26 +-- 17 files changed, 639 insertions(+), 173 deletions(-) create mode 100644 src/web/family_renderers/file.rs create mode 100644 src/web/family_renderers/generic.rs create mode 100644 src/web/family_renderers/mod.rs create mode 100644 src/web/family_renderers/nutrition.rs delete mode 100644 src/web/nutrition.rs diff --git a/docs/server.md b/docs/server.md index 16cbb1af..525b2f5a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -53,7 +53,7 @@ cook server --host ## Custom Metadata Families (e.g. Nutrition) -Beyond the [standard Cooklang metadata keys](https://cooklang.org/docs/spec/#canonical-metadata) (`servings`, `time`, `course`, `author`, ...), any YAML frontmatter key whose value is a **list** is shown on the recipe page as its own line below the tags, grouped by key — one line per family. +Beyond the [standard Cooklang metadata keys](https://cooklang.org/docs/spec/#canonical-metadata) (`servings`, `time`, `course`, `author`, ...), any YAML frontmatter key whose value is a **list** or a **mapping** is shown on the recipe page as its own line below the tags, grouped by key — one line per family. This works for any such key, not just `nutrition` — e.g. `allergens:` below renders as its own "Allergens" line automatically, with no code changes required. ```yaml --- @@ -61,20 +61,40 @@ tags: - vegan - gluten-free nutrition: - - 258%kcal - - 4.2%g of proteins - - 4.8%g of lipids - - 39.4%g of sugars - - 5.3%g of fibers + kcal: 258 + proteins: 4.2 + lipids: 4.8 + sugars: 39.4 + fibers: 5.3 +file: + created-by: Yannick + created-at: 2026-08-20 + modified-by: Yannick + modified-at: 2026-08-24 allergens: - gluten - tree nuts --- ``` -- Each entry is written as `value%unit` (e.g. `258%kcal`); the `%` is replaced with a space when displayed (`258 kcal`). Entries without a `%` are shown as-is. -- The family label is the YAML key, capitalized (`nutrition` → `Nutrition`). -- `tags` is never treated as a custom family — it keeps its own dedicated row above. -- Icons are only attached to the `nutrition` family, matched by keyword in the unit text: `kcal`/`cal`/`energy` (flame), `protein` (meat), `lipid`/`fat` (droplet), `sugar` (candy), `fiber`/`fibre` (wheat). Any other family, or an unrecognized nutrition unit, renders as a plain bullet with no icon. -- On the recipe **list** page, only the calorie entry from `nutrition` (the item containing `kcal`) is shown, as a compact badge next to the tags — the other custom families are only shown on the recipe detail page, to keep list cards compact. -- This works for any list-valued key, not just `nutrition` — e.g. `allergens:` above renders as its own "Allergens" line automatically, with no code changes required. +### Two forms + +- **Mapping** (recommended, shown above): `field: value`. A bare number gets its unit inferred from the field name for `nutrition` (`kcal` → `kcal`, everything else → `g`); `field: "45.3%g"` also works if you want to spell out a different unit. +- **List** (legacy, still supported): `- "258%kcal"`. The `%` is replaced with a space when displayed (`258 kcal`); entries without a `%` are shown as-is. Because list entries are free text, they aren't translated — write them in whichever language you want displayed. + +### Hiding a family or a single entry + +Prefix a key with `.` to hide it from the recipe page: `.internal-notes:` hides the whole family, and `.lipids:` (inside `nutrition:`) hides just that one entry while the rest of the family still shows. `tags` is never treated as a custom family — it keeps its own dedicated row above, and can't be hidden this way. + +### Specific renderers: `nutrition` and `file`/`meta` + +Two families get dedicated icons and (for `nutrition`) localized labels, matched on their fields: + +- **`nutrition`**: `kcal`/`cal`/`energy` (flame), `proteins` (meat), `lipids`/`fat` (droplet), `saturated-fat` (filled droplet), `carbohydrates`/`carbs` (bread), `sugars` (candy), `fibers`/`fibre` (wheat), `salt`/`sodium` (salt shaker). The nutrient name (everything but `kcal`, which needs none) is translated into the viewer's UI language — see [Localization](build.md#localization) for the supported locales. +- **`file`** (or `meta`, both work): `created-by`/`created-at`/`modified-by`/`modified-at` — person, calendar, pencil, and history icons respectively, with a translated `"Label: value"` line (e.g. `"Modified at: 2026-08-24"`, `"Modifié le : 2026-08-24"` in French, with the French space before `:`). + +Every other family (like `allergens` above) falls back to a generic rendering: list entries as-authored, mapping entries as `"field: value"`, no icon. Adding a third specific renderer means adding a case in `src/web/family_renderers/mod.rs`'s `renderer_for` plus a small renderer file next to `nutrition.rs`/`file.rs` — there's no filename-based auto-discovery (Rust has no runtime filesystem scanning for this), so that match statement is always the definitive list of which families get special treatment. + +### Recipe list page + +Only the calorie entry from `nutrition` (`kcal`) is shown, as a compact badge next to the tags — the other custom families are only shown on the recipe detail page, to keep list cards compact. diff --git a/locales/de-DE/recipes.ftl b/locales/de-DE/recipes.ftl index c80a1f2a..03a3c55d 100644 --- a/locales/de-DE/recipes.ftl +++ b/locales/de-DE/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Gesamtzeit meta-servings = Portionen meta-difficulty = Schwierigkeit meta-description = Beschreibung +meta-created-by = Erstellt von +meta-created-at = Erstellt am +meta-modified-by = Geändert von +meta-modified-at = Geändert am + +# Nutrition (benutzerdefinierte `nutrition:`-Metadaten) +nutrition-proteins = Eiweiß +nutrition-lipids = Fett +nutrition-saturated-fat = gesättigte Fettsäuren +nutrition-carbohydrates = Kohlenhydrate +nutrition-sugars = Zucker +nutrition-fibers = Ballaststoffe +nutrition-salt = Salz # Recipe Types recipe-type-menu = Menü diff --git a/locales/en-US/recipes.ftl b/locales/en-US/recipes.ftl index 1b4efd52..b7dd2af6 100644 --- a/locales/en-US/recipes.ftl +++ b/locales/en-US/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Total Time meta-servings = Servings meta-difficulty = Difficulty meta-description = Description +meta-created-by = Created by +meta-created-at = Created at +meta-modified-by = Modified by +meta-modified-at = Modified at + +# Nutrition (custom `nutrition:` metadata) +nutrition-proteins = proteins +nutrition-lipids = fat +nutrition-saturated-fat = saturated fat +nutrition-carbohydrates = carbohydrates +nutrition-sugars = sugars +nutrition-fibers = fiber +nutrition-salt = salt # Recipe Types recipe-type-menu = Menu diff --git a/locales/es-ES/recipes.ftl b/locales/es-ES/recipes.ftl index 171455b1..31ba1b13 100644 --- a/locales/es-ES/recipes.ftl +++ b/locales/es-ES/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Tiempo Total meta-servings = Porciones meta-difficulty = Dificultad meta-description = Descripción +meta-created-by = Creado por +meta-created-at = Creado el +meta-modified-by = Modificado por +meta-modified-at = Modificado el + +# Nutrición (metadatos personalizados `nutrition:`) +nutrition-proteins = proteínas +nutrition-lipids = grasas +nutrition-saturated-fat = grasas saturadas +nutrition-carbohydrates = hidratos de carbono +nutrition-sugars = azúcares +nutrition-fibers = fibra +nutrition-salt = sal # Recipe Types recipe-type-menu = Menú diff --git a/locales/eu-ES/recipes.ftl b/locales/eu-ES/recipes.ftl index 688acca8..7ebc1f17 100644 --- a/locales/eu-ES/recipes.ftl +++ b/locales/eu-ES/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Denbora guztira meta-servings = Anoak meta-difficulty = Zailtasuna meta-description = Deskribapena +meta-created-by = Sortzailea +meta-created-at = Sortze data +meta-modified-by = Aldatzailea +meta-modified-at = Aldatze data + +# Nutrizioa (`nutrition:` metadatu pertsonalizatuak) +nutrition-proteins = proteinak +nutrition-lipids = gantzak +nutrition-saturated-fat = gantz aseak +nutrition-carbohydrates = karbohidratoak +nutrition-sugars = azukreak +nutrition-fibers = zuntza +nutrition-salt = gatza # Recipe Types recipe-type-menu = Menua diff --git a/locales/fr-FR/recipes.ftl b/locales/fr-FR/recipes.ftl index 899ef28c..680073a1 100644 --- a/locales/fr-FR/recipes.ftl +++ b/locales/fr-FR/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Temps Total meta-servings = Portions meta-difficulty = Difficulté meta-description = Description +meta-created-by = Créé par +meta-created-at = Créé le +meta-modified-by = Modifié par +meta-modified-at = Modifié le + +# Nutrition (métadonnées personnalisées `nutrition:`) +nutrition-proteins = protéines +nutrition-lipids = lipides +nutrition-saturated-fat = graisses saturées +nutrition-carbohydrates = glucides +nutrition-sugars = sucres +nutrition-fibers = fibres +nutrition-salt = sel # Recipe Types recipe-type-menu = Menu diff --git a/locales/nl-NL/recipes.ftl b/locales/nl-NL/recipes.ftl index b6c84fda..d13f1b9c 100644 --- a/locales/nl-NL/recipes.ftl +++ b/locales/nl-NL/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Totale tijd meta-servings = Porties meta-difficulty = Moeilijkheidsgraad meta-description = Beschrijving +meta-created-by = Aangemaakt door +meta-created-at = Aangemaakt op +meta-modified-by = Gewijzigd door +meta-modified-at = Gewijzigd op + +# Nutrition (aangepaste `nutrition:`-metadata) +nutrition-proteins = eiwitten +nutrition-lipids = vetten +nutrition-saturated-fat = verzadigd vet +nutrition-carbohydrates = koolhydraten +nutrition-sugars = suikers +nutrition-fibers = vezels +nutrition-salt = zout # Recipe Types recipe-type-menu = Menu diff --git a/locales/sv-SE/recipes.ftl b/locales/sv-SE/recipes.ftl index 77256c5d..44f06676 100644 --- a/locales/sv-SE/recipes.ftl +++ b/locales/sv-SE/recipes.ftl @@ -36,6 +36,19 @@ meta-total-time = Total tid meta-servings = Portioner meta-difficulty = Svårighet meta-description = Beskrivning +meta-created-by = Skapad av +meta-created-at = Skapad +meta-modified-by = Ändrad av +meta-modified-at = Ändrad + +# Nutrition (anpassad `nutrition:`-metadata) +nutrition-proteins = protein +nutrition-lipids = fett +nutrition-saturated-fat = mättat fett +nutrition-carbohydrates = kolhydrater +nutrition-sugars = socker +nutrition-fibers = fibrer +nutrition-salt = salt # Recipe Types recipe-type-menu = Meny diff --git a/src/web/builders.rs b/src/web/builders.rs index 3ff83bae..e6396c0f 100644 --- a/src/web/builders.rs +++ b/src/web/builders.rs @@ -108,7 +108,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result) -> Result