const RECIPES_URL = "recipes.json";
const state = {
recipes: [],
selectedId: null,
sliderValues: {}, // { [recipeId]: { hydration, doughBalls, doughBallSizeG } }
markdownCache: {}, // { [recipeId]: htmlString }
};
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(//g, ">");
}
function inlineMarkdown(text) {
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, "$1");
}
function markdownToHtml(md) {
const lines = md.split("\n");
let html = "";
let inList = false;
const closeList = () => {
if (inList) {
html += "";
inList = false;
}
};
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
closeList();
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
closeList();
const level = Math.min(headingMatch[1].length + 1, 6); // ## -> h3
html += `
${inlineMarkdown(line)}
`; } closeList(); return html; } function computeIngredients(recipe, values) { const scaleFactor = (values.doughBalls * values.doughBallSizeG) / (recipe.baseDoughBalls * recipe.baseDoughBallSizeG); const baseFlour = recipe.ingredients.find((i) => i.role === "flour").baseAmount; const baseWater = recipe.ingredients.find((i) => i.role === "water").baseAmount; const flourWaterSum = (baseFlour + baseWater) * scaleFactor; const flour = flourWaterSum / (1 + values.hydration / 100); const water = flourWaterSum - flour; return recipe.ingredients.map((ing) => { let amount; if (ing.role === "flour") amount = flour; else if (ing.role === "water") amount = water; else amount = ing.baseAmount * scaleFactor; return { ...ing, amount: Math.round(amount) }; }); } function renderPicker() { const nav = document.getElementById("recipe-picker"); nav.innerHTML = ""; for (const recipe of state.recipes) { const btn = document.createElement("button"); btn.textContent = recipe.name; btn.className = recipe.id === state.selectedId ? "active" : ""; btn.addEventListener("click", () => selectRecipe(recipe.id)); nav.appendChild(btn); } } function baseHydration(recipe) { const flour = recipe.ingredients.find((i) => i.role === "flour").baseAmount; const water = recipe.ingredients.find((i) => i.role === "water").baseAmount; return Math.round((water / flour) * 100); } async function getInstructionsHtml(recipe) { if (state.markdownCache[recipe.id]) return state.markdownCache[recipe.id]; const res = await fetch(recipe.instructionsFile); if (!res.ok) throw new Error(`Failed to load ${recipe.instructionsFile}`); const md = await res.text(); const html = markdownToHtml(md); state.markdownCache[recipe.id] = html; return html; } function renderIngredients(recipe) { const values = state.sliderValues[recipe.id]; const ingredients = computeIngredients(recipe, values); const list = ingredients .map( (ing) => `${escapeHtml(recipe.description)}
Loading instructions…
${escapeHtml(err.message)}
`; } } function selectRecipe(id) { state.selectedId = id; renderPicker(); const recipe = state.recipes.find((r) => r.id === id); renderRecipePanel(recipe); } async function init() { try { const res = await fetch(RECIPES_URL); if (!res.ok) throw new Error(`Failed to load ${RECIPES_URL}`); state.recipes = await res.json(); if (state.recipes.length === 0) { document.getElementById("recipe-panel").innerHTML = 'No recipes found.
'; return; } selectRecipe(state.recipes[0].id); } catch (err) { document.getElementById("recipe-panel").innerHTML = `${escapeHtml(err.message)} — this app must be served over http(s), not opened directly as a file.
`; } } init();