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) ? `` : 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(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) => `${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();