i18n(recipes): bootstrap namespace + migrate layout, NutritionSummary

Two-locale recipes dictionary lands at src/lib/i18n/recipes/{de,en}.ts
with the same satisfies-based completeness enforcement as the other
namespaces. recipesI18n.ts is the slim shim — exports m, RecipesLang,
RecipesKey, plus langFromRecipeSlug / recipeSlugFromLang helpers for
the rezepte ↔ recipes URL slug mapping.

[recipeLang]/+layout.svelte's nav-label ternary chain collapses into
t.foo lookups. NutritionSummary.svelte is the heavy hitter — 33
inline isEnglish ternaries become a single dictionary load. Most
amino-acid names use a German-stem-plus-optional-e pattern in the old
code (`Lysin{isEnglish ? 'e' : ''}`) that's now just t.lysine in the
template; less clever, much more obviously translatable.
This commit is contained in:
2026-05-01 13:22:59 +02:00
parent d7f96f35c2
commit d540b82e85
5 changed files with 179 additions and 40 deletions
@@ -1,8 +1,12 @@
<script>
import { createNutritionCalculator } from '$lib/js/nutrition.svelte';
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let { flatIngredients, nutritionMappings, sectionNames, referencedNutrition, multiplier, portions, isEnglish, actions } = $props();
const lang = $derived(/** @type {RecipesLang} */ (isEnglish ? 'en' : 'de'));
const t = $derived(m[lang]);
const nutrition = createNutritionCalculator(
() => flatIngredients,
@@ -43,20 +47,20 @@
});
const labels = $derived({
title: isEnglish ? 'Nutrition' : 'Nährwerte',
perPortion: isEnglish ? 'per portion' : 'pro Portion',
protein: isEnglish ? 'Protein' : 'Eiweiß',
fat: isEnglish ? 'Fat' : 'Fett',
carbs: isEnglish ? 'Carbs' : 'Kohlenh.',
fiber: isEnglish ? 'Fiber' : 'Ballaststoffe',
sugars: isEnglish ? 'Sugars' : 'Zucker',
saturatedFat: isEnglish ? 'Sat. Fat' : 'Ges. Fett',
details: isEnglish ? 'Details' : 'Details',
vitamins: isEnglish ? 'Vitamins' : 'Vitamine',
minerals: isEnglish ? 'Minerals' : 'Mineralstoffe',
coverage: isEnglish ? 'coverage' : 'Abdeckung',
unmapped: isEnglish ? 'Not tracked' : 'Nicht erfasst',
aminoAcids: isEnglish ? 'Amino Acids' : 'Aminosäuren',
title: t.nutrition,
perPortion: t.per_portion,
protein: t.protein,
fat: t.fat,
carbs: t.carbs,
fiber: t.fiber,
sugars: t.sugars,
saturatedFat: t.saturated_fat,
details: t.details,
vitamins: t.vitamins,
minerals: t.minerals,
coverage: t.coverage,
unmapped: t.not_tracked,
aminoAcids: t.amino_acids
});
const hasAminoAcids = $derived.by(() => {
@@ -195,33 +199,33 @@
<div class="detail-section">
<h4>{labels.minerals}</h4>
<div class="detail-row"><span>Calcium</span><span>{fmt(nutrition.totalMicros.calcium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Iron' : 'Eisen'}</span><span>{fmt(nutrition.totalMicros.iron / div)} mg</span></div>
<div class="detail-row"><span>{t.iron}</span><span>{fmt(nutrition.totalMicros.iron / div)} mg</span></div>
<div class="detail-row"><span>Magnesium</span><span>{fmt(nutrition.totalMicros.magnesium / div)} mg</span></div>
<div class="detail-row"><span>Potassium</span><span>{fmt(nutrition.totalMicros.potassium / div)} mg</span></div>
<div class="detail-row"><span>Sodium</span><span>{fmt(nutrition.totalMicros.sodium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Zinc' : 'Zink'}</span><span>{fmt(nutrition.totalMicros.zinc / div)} mg</span></div>
<div class="detail-row"><span>{t.zinc}</span><span>{fmt(nutrition.totalMicros.zinc / div)} mg</span></div>
</div>
{#if hasAminoAcids}
<div class="detail-section">
<h4>{labels.aminoAcids}</h4>
<div class="detail-row"><span>{isEnglish ? 'Leucine' : 'Leucin'}</span><span>{fmt(nutrition.totalAminoAcids.leucine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Isoleucine' : 'Isoleucin'}</span><span>{fmt(nutrition.totalAminoAcids.isoleucine / div)} g</span></div>
<div class="detail-row"><span>Valin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.valine / div)} g</span></div>
<div class="detail-row"><span>Lysin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.lysine / div)} g</span></div>
<div class="detail-row"><span>Methionin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.methionine / div)} g</span></div>
<div class="detail-row"><span>Phenylalanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.phenylalanine / div)} g</span></div>
<div class="detail-row"><span>Threonin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.threonine / div)} g</span></div>
<div class="detail-row"><span>{t.leucine}</span><span>{fmt(nutrition.totalAminoAcids.leucine / div)} g</span></div>
<div class="detail-row"><span>{t.isoleucine}</span><span>{fmt(nutrition.totalAminoAcids.isoleucine / div)} g</span></div>
<div class="detail-row"><span>{t.valine}</span><span>{fmt(nutrition.totalAminoAcids.valine / div)} g</span></div>
<div class="detail-row"><span>{t.lysine}</span><span>{fmt(nutrition.totalAminoAcids.lysine / div)} g</span></div>
<div class="detail-row"><span>{t.methionine}</span><span>{fmt(nutrition.totalAminoAcids.methionine / div)} g</span></div>
<div class="detail-row"><span>{t.phenylalanine}</span><span>{fmt(nutrition.totalAminoAcids.phenylalanine / div)} g</span></div>
<div class="detail-row"><span>{t.threonine}</span><span>{fmt(nutrition.totalAminoAcids.threonine / div)} g</span></div>
<div class="detail-row"><span>Tryptophan</span><span>{fmt(nutrition.totalAminoAcids.tryptophan / div)} g</span></div>
<div class="detail-row"><span>Histidin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.histidine / div)} g</span></div>
<div class="detail-row"><span>Arginin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.arginine / div)} g</span></div>
<div class="detail-row"><span>Alanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.alanine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Aspartic Acid' : 'Asparaginsäure'}</span><span>{fmt(nutrition.totalAminoAcids.asparticAcid / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Cysteine' : 'Cystein'}</span><span>{fmt(nutrition.totalAminoAcids.cysteine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Glutamic Acid' : 'Glutaminsäure'}</span><span>{fmt(nutrition.totalAminoAcids.glutamicAcid / div)} g</span></div>
<div class="detail-row"><span>Glycin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.glycine / div)} g</span></div>
<div class="detail-row"><span>Prolin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.proline / div)} g</span></div>
<div class="detail-row"><span>Serin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.serine / div)} g</span></div>
<div class="detail-row"><span>Tyrosin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.tyrosine / div)} g</span></div>
<div class="detail-row"><span>{t.histidine}</span><span>{fmt(nutrition.totalAminoAcids.histidine / div)} g</span></div>
<div class="detail-row"><span>{t.arginine}</span><span>{fmt(nutrition.totalAminoAcids.arginine / div)} g</span></div>
<div class="detail-row"><span>{t.alanine}</span><span>{fmt(nutrition.totalAminoAcids.alanine / div)} g</span></div>
<div class="detail-row"><span>{t.aspartic_acid}</span><span>{fmt(nutrition.totalAminoAcids.asparticAcid / div)} g</span></div>
<div class="detail-row"><span>{t.cysteine}</span><span>{fmt(nutrition.totalAminoAcids.cysteine / div)} g</span></div>
<div class="detail-row"><span>{t.glutamic_acid}</span><span>{fmt(nutrition.totalAminoAcids.glutamicAcid / div)} g</span></div>
<div class="detail-row"><span>{t.glycine}</span><span>{fmt(nutrition.totalAminoAcids.glycine / div)} g</span></div>
<div class="detail-row"><span>{t.proline}</span><span>{fmt(nutrition.totalAminoAcids.proline / div)} g</span></div>
<div class="detail-row"><span>{t.serine}</span><span>{fmt(nutrition.totalAminoAcids.serine / div)} g</span></div>
<div class="detail-row"><span>{t.tyrosine}</span><span>{fmt(nutrition.totalAminoAcids.tyrosine / div)} g</span></div>
</div>
{/if}
</div>
+46
View File
@@ -0,0 +1,46 @@
/** German recipes UI strings — source of truth for the key set. */
export const de = {
// Layout / nav
all_recipes: 'Alle Rezepte',
favorites: 'Favoriten',
season_nav: 'Saison',
category_nav: 'Kategorie',
icon_nav: 'Icon',
tags_nav: 'Tags',
// Nutrition summary
nutrition: 'Nährwerte',
per_portion: 'pro Portion',
protein: 'Eiweiß',
fat: 'Fett',
carbs: 'Kohlenh.',
fiber: 'Ballaststoffe',
sugars: 'Zucker',
saturated_fat: 'Ges. Fett',
details: 'Details',
vitamins: 'Vitamine',
minerals: 'Mineralstoffe',
coverage: 'Abdeckung',
not_tracked: 'Nicht erfasst',
amino_acids: 'Aminosäuren',
iron: 'Eisen',
zinc: 'Zink',
leucine: 'Leucin',
isoleucine: 'Isoleucin',
valine: 'Valin',
lysine: 'Lysin',
methionine: 'Methionin',
phenylalanine: 'Phenylalanin',
threonine: 'Threonin',
histidine: 'Histidin',
arginine: 'Arginin',
alanine: 'Alanin',
aspartic_acid: 'Asparaginsäure',
cysteine: 'Cystein',
glutamic_acid: 'Glutaminsäure',
glycine: 'Glycin',
proline: 'Prolin',
serine: 'Serin',
tyrosine: 'Tyrosin'
} as const;
+46
View File
@@ -0,0 +1,46 @@
import type { de } from './de';
export const en = {
// Layout / nav
all_recipes: 'All Recipes',
favorites: 'Favorites',
season_nav: 'Season',
category_nav: 'Category',
icon_nav: 'Icon',
tags_nav: 'Tags',
// Nutrition summary
nutrition: 'Nutrition',
per_portion: 'per portion',
protein: 'Protein',
fat: 'Fat',
carbs: 'Carbs',
fiber: 'Fiber',
sugars: 'Sugars',
saturated_fat: 'Sat. Fat',
details: 'Details',
vitamins: 'Vitamins',
minerals: 'Minerals',
coverage: 'coverage',
not_tracked: 'Not tracked',
amino_acids: 'Amino Acids',
iron: 'Iron',
zinc: 'Zinc',
leucine: 'Leucine',
isoleucine: 'Isoleucine',
valine: 'Valine',
lysine: 'Lysine',
methionine: 'Methionine',
phenylalanine: 'Phenylalanine',
threonine: 'Threonine',
histidine: 'Histidine',
arginine: 'Arginine',
alanine: 'Alanine',
aspartic_acid: 'Aspartic Acid',
cysteine: 'Cysteine',
glutamic_acid: 'Glutamic Acid',
glycine: 'Glycine',
proline: 'Proline',
serine: 'Serine',
tyrosine: 'Tyrosine'
} as const satisfies Record<keyof typeof de, string>;
+39
View File
@@ -0,0 +1,39 @@
/**
* Recipes route i18n.
*
* Translation tables live per-locale in `$lib/i18n/recipes/{de,en}.ts`.
* `de.ts` is the source of truth for the key set; `en.ts` uses
* `satisfies Record<keyof typeof de, string>` so missing translations
* fail the build.
*
* Recipes routes get `lang` from the layout server load (data.lang),
* derived from the [recipeLang=recipeLang] param matcher: rezepte→de,
* recipes→en. Use `langFromRecipeSlug(params.recipeLang)` if you need it
* from the slug directly.
*/
import { de } from '$lib/i18n/recipes/de';
import { en } from '$lib/i18n/recipes/en';
export const m = { de, en } as const;
export type RecipesLang = keyof typeof m;
export type RecipesKey = keyof typeof de;
/** Map a `[recipeLang]` slug to the locale code. */
export function langFromRecipeSlug(recipeLang: string | null | undefined): RecipesLang {
return recipeLang === 'recipes' ? 'en' : 'de';
}
/** Reverse: locale → URL slug. */
export function recipeSlugFromLang(lang: RecipesLang): 'recipes' | 'rezepte' {
return lang === 'en' ? 'recipes' : 'rezepte';
}
/**
* Get a translated string. Prefer `m[lang].key` directly in new code —
* this helper is kept for incremental migration.
*/
export function t(key: RecipesKey, lang: RecipesLang): string {
return m[lang][key] ?? m.en[key] ?? key;
}
@@ -56,14 +56,18 @@ let { data, children } = $props();
let user = $derived(data.session?.user);
const isEnglish = $derived(data.lang === 'en');
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
const lang = $derived(/** @type {RecipesLang} */ (data.lang));
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
const labels = $derived({
allRecipes: isEnglish ? 'All Recipes' : 'Alle Rezepte',
favorites: isEnglish ? 'Favorites' : 'Favoriten',
inSeason: isEnglish ? 'Season' : 'Saison',
category: isEnglish ? 'Category' : 'Kategorie',
icon: 'Icon',
keywords: 'Tags'
allRecipes: t.all_recipes,
favorites: t.favorites,
inSeason: t.season_nav,
category: t.category_nav,
icon: t.icon_nav,
keywords: t.tags_nav
});
/** @param {string} path */