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 isSafeUrl(url) { if (/["'<>]/.test(url)) return false; return /^https?:\/\//i.test(url) || /^[\w./-]+$/.test(url); } function inlineMarkdown(text) { return escapeHtml(text) .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => isSafeUrl(url) ? `${alt}` : match ) .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) => isSafeUrl(url) ? `${label}` : match ); } 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(headingMatch[2])}`; continue; } const listMatch = line.match(/^\d+\.\s+(.*)$/); if (listMatch) { if (!inList) { html += "
    "; inList = true; } html += `
  1. ${inlineMarkdown(listMatch[1])}
  2. `; continue; } closeList(); html += `

    ${inlineMarkdown(line)}

    `; } closeList(); return html; } // Recipes can list more than one flour or water (a poolish has its own), so the // hydration slider works on the totals and splits them back over the components. function baseRoleTotal(recipe, role) { return recipe.ingredients .filter((i) => i.role === role) .reduce((sum, i) => sum + i.baseAmount, 0); } // Yeast and honey are measured in fractions of a gram, so small amounts keep decimals. function roundAmount(amount) { if (amount >= 10) return Math.round(amount); if (amount >= 1) return Math.round(amount * 10) / 10; return Math.round(amount * 100) / 100; } function computeIngredients(recipe, values) { const scaleFactor = (values.doughBalls * values.doughBallSizeG) / (recipe.baseDoughBalls * recipe.baseDoughBallSizeG); const baseFlour = baseRoleTotal(recipe, "flour"); const baseWater = baseRoleTotal(recipe, "water"); 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 * (ing.baseAmount / baseFlour); else if (ing.role === "water") amount = water * (ing.baseAmount / baseWater); else amount = ing.baseAmount * scaleFactor; return { ...ing, amount: roundAmount(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 = baseRoleTotal(recipe, "flour"); const water = baseRoleTotal(recipe, "water"); 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) => `
  3. ${escapeHtml(ing.name)}${ing.amount}${ing.unit}
  4. ` ) .join(""); const container = document.getElementById("ingredients-list"); if (container) container.innerHTML = list; } function onSliderChange(recipe) { const panel = document.getElementById("recipe-panel"); const hydrationInput = panel.querySelector("#slider-hydration"); const ballsInput = panel.querySelector("#slider-balls"); const sizeInput = panel.querySelector("#slider-size"); const values = { hydration: Number(hydrationInput.value), doughBalls: Number(ballsInput.value), doughBallSizeG: Number(sizeInput.value), }; state.sliderValues[recipe.id] = values; panel.querySelector("#value-hydration").textContent = `${values.hydration}%`; panel.querySelector("#value-balls").textContent = values.doughBalls; panel.querySelector("#value-size").textContent = `${values.doughBallSizeG}g`; renderIngredients(recipe); } async function renderRecipePanel(recipe) { const panel = document.getElementById("recipe-panel"); if (!state.sliderValues[recipe.id]) { state.sliderValues[recipe.id] = { hydration: baseHydration(recipe), doughBalls: recipe.baseDoughBalls, doughBallSizeG: recipe.baseDoughBallSizeG, }; } const values = state.sliderValues[recipe.id]; panel.innerHTML = `

    ${escapeHtml(recipe.name)}

    ${escapeHtml(recipe.description)}

    Difficulty: ${escapeHtml(recipe.difficulty)} Total time: ${recipe.totalTimeHours}h

    Ingredients

      Loading instructions…

      `; ["slider-hydration", "slider-balls", "slider-size"].forEach((id) => { panel.querySelector(`#${id}`).addEventListener("input", () => onSliderChange(recipe)); }); renderIngredients(recipe); try { const html = await getInstructionsHtml(recipe); const instructionsEl = document.getElementById("instructions"); if (instructionsEl) instructionsEl.innerHTML = html; } catch (err) { const instructionsEl = document.getElementById("instructions"); if (instructionsEl) instructionsEl.innerHTML = `

      ${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();