Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,51 @@ 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** 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
---
tags:
- vegan
- gluten-free
nutrition:
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
---
```

### 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.
14 changes: 14 additions & 0 deletions locales/de-DE/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = pro Portion
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ü
Expand Down
14 changes: 14 additions & 0 deletions locales/en-US/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = per serving
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
Expand Down
14 changes: 14 additions & 0 deletions locales/es-ES/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = por porción
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ú
Expand Down
14 changes: 14 additions & 0 deletions locales/eu-ES/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = anoko
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
Expand Down
14 changes: 14 additions & 0 deletions locales/fr-FR/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = par portion
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
Expand Down
14 changes: 14 additions & 0 deletions locales/nl-NL/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = per portie
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
Expand Down
14 changes: 14 additions & 0 deletions locales/sv-SE/recipes.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ 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-per-serving = per portion
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
Expand Down
26 changes: 24 additions & 2 deletions src/web/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem
};

// Extract tags, image, is_menu, and file timestamps if this is a recipe
let (tags, image_path, is_menu, modified_at, created_at) =
let (tags, nutrition_kcal, image_path, is_menu, modified_at, created_at) =
if let Some(ref recipe) = child.recipe {
let img_path = recipe.title_image().clone().and_then(|img| {
if img.starts_with("http://") || img.starts_with("https://") {
Expand Down Expand Up @@ -108,13 +108,14 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem

(
recipe.tags(),
crate::web::family_renderers::extract_nutrition_kcal(recipe.metadata()),
img_path,
recipe.is_menu(),
modified_at,
created_at,
)
} else {
(Vec::new(), None, false, None, None)
(Vec::new(), None, None, false, None, None)
};

items.push(RecipeItem {
Expand All @@ -128,6 +129,7 @@ pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result<RecipesTem
},
description: None,
tags,
nutrition_kcal,
image_path,
is_menu,
modified_at,
Expand Down Expand Up @@ -715,11 +717,20 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
if key_str.starts_with("source.") || key_str.starts_with("time.") {
continue;
}
// A key prefixed with "." is hidden from the recipe page.
if key_str.starts_with('.') {
continue;
}

custom_metadata.push((key_str.to_string(), val_str.to_string()));
}
}

let custom_lists = crate::web::family_renderers::build_custom_list_families(
recipe.metadata.map_filtered(),
&lang,
);

Some(RecipeMetadata {
servings: get_field("servings"),
time: get_field("time"),
Expand All @@ -740,6 +751,7 @@ pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result<RecipeBuildO
source: get_field("source").or_else(|| get_field("source.name")),
source_url: get_field("source.url"),
custom: custom_metadata,
custom_lists,
})
};

Expand Down Expand Up @@ -1007,10 +1019,19 @@ fn build_menu_template_inner(
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()) {
// A key prefixed with "." is hidden from the recipe page.
if key_str.starts_with('.') {
continue;
}
custom_metadata.push((key_str.to_string(), val_str.to_string()));
}
}

let custom_lists = crate::web::family_renderers::build_custom_list_families(
recipe.metadata.map_filtered(),
&lang,
);

Some(RecipeMetadata {
servings: get_field("servings"),
time: get_field("time"),
Expand All @@ -1029,6 +1050,7 @@ fn build_menu_template_inner(
source: get_field("source").or_else(|| get_field("source.name")),
source_url: get_field("source.url"),
custom: custom_metadata,
custom_lists,
})
};

Expand Down
75 changes: 75 additions & 0 deletions src/web/family_renderers/file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// MIT License
//
// Copyright (c) 2024 cooklang
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

//! Specific renderer for the `file:`/`meta:` family (`created-by`,
//! `created-at`, `modified-by`, `modified-at`).

use super::{format_mapping_entry_default, FamilyRenderer};
use fluent_templates::Loader;
use unic_langid::LanguageIdentifier;

/// Tabler Icons (MIT license, https://tabler.io/icons).
const ICON_USER: &str = r##"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.5"/><path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6"/></svg>"##;
const ICON_CALENDAR: &str = r##"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M8 3v4M16 3v4"/></svg>"##;
const ICON_PENCIL: &str = r##"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 4.5 19 8l-10 10H5v-4Z"/></svg>"##;
const ICON_HISTORY: &str = r##"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9a8 8 0 1 1 1.5 6.5"/><path d="M4 4v5h5"/><path d="M12 8v4l3 2"/></svg>"##;

/// Specific renderer for the `file`/`meta` family.
pub(super) struct FileRender;

impl FamilyRenderer for FileRender {
fn mapping_icon(&self, field: &str) -> Option<&'static str> {
match field.to_lowercase().replace('_', "-").as_str() {
"created-by" => Some(ICON_USER),
"created-at" => Some(ICON_CALENDAR),
"modified-by" => Some(ICON_PENCIL),
"modified-at" => Some(ICON_HISTORY),
_ => None,
}
}

/// `("created-by", "Yannick")` -> `"Created by: Yannick"` (en) /
/// `"Créé par : Yannick"` (fr, with the French space before `:`) /
/// `"Created by: Yannick"` (en), via Fluent. Any other field falls back
/// to the raw field name, same as [`super::generic::GenericRenderer`].
fn format_mapping_entry(&self, field: &str, raw: &str, lang: &LanguageIdentifier) -> String {
let key = match field.to_lowercase().replace('_', "-").as_str() {
"created-by" => "meta-created-by",
"created-at" => "meta-created-at",
"modified-by" => "meta-modified-by",
"modified-at" => "meta-modified-at",
_ => return format_mapping_entry_default(field, raw),
};
let label = crate::web::i18n::LOCALES.lookup(lang, key);
format!("{label}{}{}", colon_separator(lang), raw.trim())
}
}

/// French typography puts a (narrow no-break) space before `:`; other
/// supported locales just follow it with a space.
fn colon_separator(lang: &LanguageIdentifier) -> &'static str {
if lang.language == "fr" {
"\u{202f}: "
} else {
": "
}
}
31 changes: 31 additions & 0 deletions src/web/family_renderers/generic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// MIT License
//
// Copyright (c) 2024 cooklang
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

use super::FamilyRenderer;

/// Fallback renderer for any family without a specific one: list entries are
/// shown as-authored (`"value%unit"` -> `"value unit"`), mapping entries as
/// `"field: value"`. No icons. Used by [`super::renderer_for`] whenever a
/// family key doesn't match a specific renderer.
pub(super) struct GenericRenderer;

impl FamilyRenderer for GenericRenderer {}
Loading
Loading