const FRACTIONS = new Map([ ["¼", 1 / 4], ["½", 1 / 2], ["¾", 3 / 4], ["⅓", 1 / 3], ["⅔", 2 / 3], ["⅛", 1 / 8], ["⅜", 3 / 8], ["⅝", 5 / 8], ["⅞", 7 / 8] ]); function parseYield(value) { const match = String(value ?? "").match(/\d+(?:\.\d+)?/); if (!match) return null; const portions = Number(match[0]); return Number.isFinite(portions) && portions > 0 ? portions : null; } function parseQuantity(value) { if (FRACTIONS.has(value)) return FRACTIONS.get(value); if (value.includes("/")) { const [numerator, denominator] = value.split("/").map(Number); return denominator ? numerator / denominator : null; } return Number(value); } function formatQuantity(value) { const whole = Math.floor(value + 1e-8); const remainder = value - whole; const candidates = [[0, ""], [1 / 8, "⅛"], [1 / 4, "¼"], [1 / 3, "⅓"], [3 / 8, "⅜"], [1 / 2, "½"], [5 / 8, "⅝"], [2 / 3, "⅔"], [3 / 4, "¾"], [7 / 8, "⅞"], [1, ""]]; const [fraction, symbol] = candidates.reduce((best, candidate) => ( Math.abs(candidate[0] - remainder) < Math.abs(best[0] - remainder) ? candidate : best )); if (Math.abs(fraction - remainder) <= 0.02) { const adjustedWhole = fraction === 1 ? whole + 1 : whole; return { value: `${adjustedWhole || ""}${adjustedWhole && symbol ? " " : ""}${symbol}` || "0", approximate: false }; } return { value: Number(value.toFixed(2)).toString(), approximate: true }; } function scaleIngredientWithStatus(ingredient, factor) { if (typeof ingredient !== "string" || factor === 1) { return { value: ingredient, status: "unchanged" }; } const match = ingredient.match(/^(\s*)(?:(\d+)\s+)?(\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅛⅜⅝⅞])(?=\s|[a-zA-Z])/); if (!match) { const hasLeadingQuantity = /^\s*(?:\d|[¼½¾⅓⅔⅛⅜⅝⅞])/.test(ingredient); return { value: ingredient, status: hasLeadingQuantity ? "unscalable" : "unquantified" }; } const whole = Number(match[2] || 0); const quantity = parseQuantity(match[3]); if (!Number.isFinite(quantity)) return { value: ingredient, status: "unscalable" }; const formatted = formatQuantity((whole + quantity) * factor); const replacement = `${match[1]}${formatted.value}`; return { value: replacement + ingredient.slice(match[0].length), status: formatted.approximate ? "approximate" : "scaled" }; } function scaleIngredient(ingredient, factor) { return scaleIngredientWithStatus(ingredient, factor).value; } function scaleRecipe(recipe, portions) { const basePortions = parseYield(recipe?.recipeYield); if (!basePortions || !Number.isInteger(portions) || portions < 1) return recipe; const suffix = String(recipe.recipeYield).replace(/^\s*\d+(?:\.\d+)?\s*/, ""); const factor = portions / basePortions; const ingredients = (recipe.recipeIngredient || []).map((ingredient) => ( scaleIngredientWithStatus(ingredient, factor) )); return { ...recipe, recipeYield: suffix ? `${portions} ${suffix}` : portions, originalRecipeYield: recipe.recipeYield, recipeIngredient: ingredients.map(({ value }) => value), recipeIngredientStatus: ingredients.map(({ status }) => status) }; } module.exports = { parseYield, scaleIngredient, scaleIngredientWithStatus, scaleRecipe };