UNPKG

@tmlmt/cooklang-parser

Version:
1,349 lines (1,340 loc) 46.3 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/classes/category_config.ts var CategoryConfig = class { /** * Creates a new CategoryConfig instance. * @param config - The category configuration to parse. */ constructor(config) { /** * The parsed categories of ingredients. */ __publicField(this, "categories", []); if (config) { this.parse(config); } } /** * Parses a category configuration from a string into property * {@link CategoryConfig.categories | categories} * @param config - The category configuration to parse. */ parse(config) { let currentCategory = null; const categoryNames = /* @__PURE__ */ new Set(); const ingredientNames = /* @__PURE__ */ new Set(); for (const line of config.split("\n")) { const trimmedLine = line.trim(); if (trimmedLine.length === 0) { continue; } if (trimmedLine.startsWith("[") && trimmedLine.endsWith("]")) { const categoryName = trimmedLine.substring(1, trimmedLine.length - 1).trim(); if (categoryNames.has(categoryName)) { throw new Error(`Duplicate category found: ${categoryName}`); } categoryNames.add(categoryName); currentCategory = { name: categoryName, ingredients: [] }; this.categories.push(currentCategory); } else { if (currentCategory === null) { throw new Error( `Ingredient found without a category: ${trimmedLine}` ); } const aliases = trimmedLine.split("|").map((s) => s.trim()); for (const alias of aliases) { if (ingredientNames.has(alias)) { throw new Error(`Duplicate ingredient/alias found: ${alias}`); } ingredientNames.add(alias); } const ingredient = { name: aliases[0], // We know this exists because trimmedLine is not empty aliases }; currentCategory.ingredients.push(ingredient); } } } }; // src/classes/section.ts var Section = class { /** * Creates an instance of Section. * @param name - The name of the section. Defaults to an empty string. */ constructor(name = "") { /** * The name of the section. Can be an empty string for the default (first) section. * @defaultValue `""` */ __publicField(this, "name"); /** An array of steps and notes that make up the content of the section. */ __publicField(this, "content", []); this.name = name; } /** * Checks if the section is blank (has no name and no content). * Used during recipe parsing * @returns `true` if the section is blank, otherwise `false`. */ isBlank() { return this.name === "" && this.content.length === 0; } }; // node_modules/.pnpm/human-regex@2.1.5_patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460/node_modules/human-regex/dist/human-regex.esm.js var t = /* @__PURE__ */ new Map(); var r = { GLOBAL: "g", NON_SENSITIVE: "i", MULTILINE: "m", DOT_ALL: "s", UNICODE: "u", STICKY: "y" }; var e = Object.freeze({ digit: "0-9", lowercaseLetter: "a-z", uppercaseLetter: "A-Z", letter: "a-zA-Z", alphanumeric: "a-zA-Z0-9", anyCharacter: "." }); var n = Object.freeze({ zeroOrMore: "*", oneOrMore: "+", optional: "?" }); var a = class { constructor() { this.parts = [], this.flags = /* @__PURE__ */ new Set(); } digit() { return this.add("\\d"); } special() { return this.add("(?=.*[!@#$%^&*])"); } word() { return this.add("\\w"); } whitespace() { return this.add("\\s"); } nonWhitespace() { return this.add("\\S"); } literal(r2) { return this.add((function(r3) { t.has(r3) || t.set(r3, r3.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); return t.get(r3); })(r2)); } or() { return this.add("|"); } range(t2) { const r2 = e[t2]; if (!r2) throw new Error(`Unknown range: ${t2}`); return this.add(`[${r2}]`); } notRange(t2) { const r2 = e[t2]; if (!r2) throw new Error(`Unknown range: ${t2}`); return this.add(`[^${r2}]`); } anyOf(t2) { return this.add(`[${t2}]`); } notAnyOf(t2) { return this.add(`[^${t2}]`); } lazy() { const t2 = this.parts.pop(); if (!t2) throw new Error("No quantifier to make lazy"); return this.add(`${t2}?`); } letter() { return this.add("[a-zA-Z]"); } anyCharacter() { return this.add("."); } newline() { return this.add("(?:\\r\\n|\\r|\\n)"); } negativeLookahead(t2) { return this.add(`(?!${t2})`); } positiveLookahead(t2) { return this.add(`(?=${t2})`); } positiveLookbehind(t2) { return this.add(`(?<=${t2})`); } negativeLookbehind(t2) { return this.add(`(?<!${t2})`); } hasSpecialCharacter() { return this.add("(?=.*[!@#$%^&*])"); } hasDigit() { return this.add("(?=.*\\d)"); } hasLetter() { return this.add("(?=.*[a-zA-Z])"); } optional() { return this.add(n.optional); } exactly(t2) { return this.add(`{${t2}}`); } atLeast(t2) { return this.add(`{${t2},}`); } atMost(t2) { return this.add(`{0,${t2}}`); } between(t2, r2) { return this.add(`{${t2},${r2}}`); } oneOrMore() { return this.add(n.oneOrMore); } zeroOrMore() { return this.add(n.zeroOrMore); } startNamedGroup(t2) { return this.add(`(?<${t2}>`); } startGroup() { return this.add("(?:"); } startCaptureGroup() { return this.add("("); } wordBoundary() { return this.add("\\b"); } nonWordBoundary() { return this.add("\\B"); } endGroup() { return this.add(")"); } startAnchor() { return this.add("^"); } endAnchor() { return this.add("$"); } global() { return this.flags.add(r.GLOBAL), this; } nonSensitive() { return this.flags.add(r.NON_SENSITIVE), this; } multiline() { return this.flags.add(r.MULTILINE), this; } dotAll() { return this.flags.add(r.DOT_ALL), this; } sticky() { return this.flags.add(r.STICKY), this; } unicodeChar(t2) { this.flags.add(r.UNICODE); const e2 = /* @__PURE__ */ new Set(["u", "l", "t", "m", "o"]); if (void 0 !== t2 && !e2.has(t2)) throw new Error(`Invalid Unicode letter variant: ${t2}`); return this.add(`\\p{L${null != t2 ? t2 : ""}}`); } unicodeDigit() { return this.flags.add(r.UNICODE), this.add("\\p{N}"); } unicodePunctuation() { return this.flags.add(r.UNICODE), this.add("\\p{P}"); } unicodeSymbol() { return this.flags.add(r.UNICODE), this.add("\\p{S}"); } repeat(t2) { if (0 === this.parts.length) throw new Error("No pattern to repeat"); const r2 = this.parts.pop(); return this.parts.push(`(${r2}){${t2}}`), this; } ipv4Octet() { return this.add("(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)"); } protocol() { return this.add("https?://"); } www() { return this.add("(www\\.)?"); } tld() { return this.add("(com|org|net)"); } path() { return this.add("(/\\w+)*"); } add(t2) { return this.parts.push(t2), this; } toString() { return this.parts.join(""); } toRegExp() { return new RegExp(this.toString(), [...this.flags].join("")); } }; var d = () => new a(); var i = (() => { const t2 = (t3) => { const r2 = t3().toRegExp(); return () => new RegExp(r2.source, r2.flags); }; return { email: t2((() => d().startAnchor().word().oneOrMore().literal("@").word().oneOrMore().startGroup().literal(".").word().oneOrMore().endGroup().zeroOrMore().literal(".").letter().atLeast(2).endAnchor())), url: t2((() => d().startAnchor().protocol().www().word().oneOrMore().literal(".").tld().path().endAnchor())), phoneInternational: t2((() => d().startAnchor().literal("+").digit().between(1, 3).literal("-").digit().between(3, 14).endAnchor())) }; })(); // src/regex.ts var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp(); var scalingMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").whitespace().zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().endGroup().optional().endGroup().endAnchor().multiline().toRegExp(); var nonWordChar = "\\s@#~\\[\\]{(,;:!?"; var multiwordIngredient = d().literal("@").startNamedGroup("mIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("mIngredientRecipeAnchor").literal("./").endGroup().optional().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").startGroup().literal("{").startNamedGroup("mIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("mIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("mIngredientUnit").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("mIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp(); var singleWordIngredient = d().literal("@").startNamedGroup("sIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("sIngredientRecipeAnchor").literal("./").endGroup().optional().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).oneOrMore().endGroup().startGroup().literal("{").startNamedGroup("sIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("sIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("sIngredientUnit").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("sIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp(); var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp(); var multiwordCookware = d().literal("#").startNamedGroup("mCookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startNamedGroup("mCookwareName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})").literal("{").startNamedGroup("mCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").toRegExp(); var singleWordCookware = d().literal("#").startNamedGroup("sCookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).oneOrMore().endGroup().startGroup().literal("{").startNamedGroup("sCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp(); var timer = d().literal("~").startNamedGroup("timerName").anyCharacter().zeroOrMore().lazy().endGroup().literal("{").startNamedGroup("timerQuantity").anyCharacter().oneOrMore().lazy().endGroup().startGroup().literal("%").startNamedGroup("timerUnit").anyCharacter().oneOrMore().lazy().endGroup().endGroup().optional().literal("}").toRegExp(); var tokensRegex = new RegExp( [ multiwordIngredient, singleWordIngredient, multiwordCookware, singleWordCookware, timer ].map((r2) => r2.source).join("|"), "gu" ); var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp(); var blockCommentRegex = d().whitespace().zeroOrMore().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp(); var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().oneOrMore().endGroup().literal("]").newline().startNamedGroup("items").anyCharacter().zeroOrMore().lazy().endGroup().startGroup().newline().newline().or().endAnchor().endGroup().global().toRegExp(); var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().literal("-").digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp(); var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp(); var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp(); // src/units.ts var units = [ // Mass (Metric) { name: "g", type: "mass", system: "metric", aliases: ["gram", "grams", "grammes"], toBase: 1 }, { name: "kg", type: "mass", system: "metric", aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"], toBase: 1e3 }, // Mass (Imperial) { name: "oz", type: "mass", system: "imperial", aliases: ["ounce", "ounces"], toBase: 28.3495 }, { name: "lb", type: "mass", system: "imperial", aliases: ["pound", "pounds"], toBase: 453.592 }, // Volume (Metric) { name: "ml", type: "volume", system: "metric", aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"], toBase: 1 }, { name: "l", type: "volume", system: "metric", aliases: ["liter", "liters", "litre", "litres"], toBase: 1e3 }, { name: "tsp", type: "volume", system: "metric", aliases: ["teaspoon", "teaspoons"], toBase: 5 }, { name: "tbsp", type: "volume", system: "metric", aliases: ["tablespoon", "tablespoons"], toBase: 15 }, // Volume (Imperial) { name: "fl-oz", type: "volume", system: "imperial", aliases: ["fluid ounce", "fluid ounces"], toBase: 29.5735 }, { name: "cup", type: "volume", system: "imperial", aliases: ["cups"], toBase: 236.588 }, { name: "pint", type: "volume", system: "imperial", aliases: ["pints"], toBase: 473.176 }, { name: "quart", type: "volume", system: "imperial", aliases: ["quarts"], toBase: 946.353 }, { name: "gallon", type: "volume", system: "imperial", aliases: ["gallons"], toBase: 3785.41 }, // Count units (no conversion, but recognized as a type) { name: "piece", type: "count", system: "metric", aliases: ["pieces", "pc"], toBase: 1 } ]; var unitMap = /* @__PURE__ */ new Map(); for (const unit of units) { unitMap.set(unit.name.toLowerCase(), unit); for (const alias of unit.aliases) { unitMap.set(alias.toLowerCase(), unit); } } function normalizeUnit(unit = "") { return unitMap.get(unit.toLowerCase().trim()); } var CannotAddTextValueError = class extends Error { constructor() { super("Cannot add a quantity with a text value."); this.name = "CannotAddTextValueError"; } }; var IncompatibleUnitsError = class extends Error { constructor(unit1, unit2) { super( `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}` ); this.name = "IncompatibleUnitsError"; } }; function gcd(a2, b) { return b === 0 ? a2 : gcd(b, a2 % b); } function simplifyFraction(num, den) { if (den === 0) { throw new Error("Denominator cannot be zero."); } const commonDivisor = gcd(Math.abs(num), Math.abs(den)); let simplifiedNum = num / commonDivisor; let simplifiedDen = den / commonDivisor; if (simplifiedDen < 0) { simplifiedNum = -simplifiedNum; simplifiedDen = -simplifiedDen; } if (simplifiedDen === 1) { return { type: "decimal", value: simplifiedNum }; } else { return { type: "fraction", num: simplifiedNum, den: simplifiedDen }; } } function multiplyNumericValue(v, factor) { if (v.type === "decimal") { return { type: "decimal", value: v.value * factor }; } return simplifyFraction(v.num * factor, v.den); } function addNumericValues(val1, val2) { let num1; let den1; let num2; let den2; if (val1.type === "decimal") { num1 = val1.value; den1 = 1; } else { num1 = val1.num; den1 = val1.den; } if (val2.type === "decimal") { num2 = val2.value; den2 = 1; } else { num2 = val2.num; den2 = val2.den; } if (num1 === 0 && num2 === 0) { return { type: "decimal", value: 0 }; } if (val1.type === "fraction" && val2.type === "fraction" || val1.type === "fraction" && val2.type === "decimal" && val2.value === 0 || val2.type === "fraction" && val1.type === "decimal" && val1.value === 0) { const commonDen = den1 * den2; const sumNum = num1 * den2 + num2 * den1; return simplifyFraction(sumNum, commonDen); } else { return { type: "decimal", value: num1 / den1 + num2 / den2 }; } } var toRoundedDecimal = (v) => { const value = v.type === "decimal" ? v.value : v.num / v.den; return { type: "decimal", value: Math.floor(value * 100) / 100 }; }; function multiplyQuantityValue(value, factor) { if (value.type === "fixed") { const newValue = multiplyNumericValue( value.value, factor ); if (factor === parseInt(factor.toString()) || // e.g. 2 === int 1 / factor === parseInt((1 / factor).toString())) { return { type: "fixed", value: newValue }; } return { type: "fixed", value: toRoundedDecimal(newValue) }; } return { type: "range", min: toRoundedDecimal(multiplyNumericValue(value.min, factor)), max: toRoundedDecimal(multiplyNumericValue(value.max, factor)) }; } var convertQuantityValue = (value, def, targetDef) => { if (def.name === targetDef.name) return value; const factor = def.toBase / targetDef.toBase; return multiplyQuantityValue(value, factor); }; function getDefaultQuantityValue() { return { type: "fixed", value: { type: "decimal", value: 0 } }; } function addQuantityValues(v1, v2) { if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") { throw new CannotAddTextValueError(); } if (v1.type === "fixed" && v2.type === "fixed") { const res = addNumericValues( v1.value, v2.value ); return { type: "fixed", value: res }; } const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value }; const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value }; const newMin = addNumericValues( r1.min, r2.min ); const newMax = addNumericValues( r1.max, r2.max ); return { type: "range", min: newMin, max: newMax }; } function addQuantities(q1, q2) { const v1 = q1.value; const v2 = q2.value; if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") { throw new CannotAddTextValueError(); } const unit1Def = normalizeUnit(q1.unit); const unit2Def = normalizeUnit(q2.unit); const addQuantityValuesAndSetUnit = (val1, val2, unit) => ({ value: addQuantityValues(val1, val2), unit }); if ((q1.unit === "" || q1.unit === void 0) && q2.unit !== void 0) { return addQuantityValuesAndSetUnit(v1, v2, q2.unit); } if ((q2.unit === "" || q2.unit === void 0) && q1.unit !== void 0) { return addQuantityValuesAndSetUnit(v1, v2, q1.unit); } if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.toLowerCase() === q2.unit.toLowerCase()) { return addQuantityValuesAndSetUnit(v1, v2, q1.unit); } if (unit1Def && unit2Def) { if (unit1Def.type !== unit2Def.type) { throw new IncompatibleUnitsError( `${unit1Def.type} (${q1.unit})`, `${unit2Def.type} (${q2.unit})` ); } let targetUnitDef; if (unit1Def.system !== unit2Def.system) { const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def; targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce( (prev, current) => prev.toBase > current.toBase ? prev : current ); } else { targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def; } const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef); const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef); return addQuantityValuesAndSetUnit( convertedV1, convertedV2, targetUnitDef.name ); } throw new IncompatibleUnitsError(q1.unit, q2.unit); } // src/errors.ts var ReferencedItemCannotBeRedefinedError = class extends Error { constructor(item_type, item_name, new_modifier) { super( `The referenced ${item_type} "${item_name}" cannot be redefined as ${new_modifier}. You can either remove the reference to create a new ${item_type} defined as ${new_modifier} or add the ${new_modifier} flag to the original definition of the ${item_type}` ); this.name = "ReferencedItemCannotBeRedefinedError"; } }; // src/parser_helpers.ts function flushPendingNote(section, note) { if (note.length > 0) { section.content.push({ type: "note", note }); return ""; } return note; } function flushPendingItems(section, items) { if (items.length > 0) { section.content.push({ type: "step", items: [...items] }); items.length = 0; return true; } return false; } function findAndUpsertIngredient(ingredients, newIngredient, isReference) { const { name, quantity, unit } = newIngredient; if (isReference) { const indexFind = ingredients.findIndex( (i2) => i2.name.toLowerCase() === name.toLowerCase() ); if (indexFind === -1) { throw new Error( `Referenced ingredient "${name}" not found. A referenced ingredient must be declared before being referenced with '&'.` ); } const existingIngredient = ingredients[indexFind]; for (const flag of newIngredient.flags) { if (!existingIngredient.flags.includes(flag)) { throw new ReferencedItemCannotBeRedefinedError( "ingredient", existingIngredient.name, flag ); } } let quantityPartIndex = void 0; if (quantity !== void 0) { const currentQuantity = { value: existingIngredient.quantity ?? getDefaultQuantityValue(), unit: existingIngredient.unit ?? "" }; const newQuantity = { value: quantity, unit: unit ?? "" }; try { const total = addQuantities(currentQuantity, newQuantity); existingIngredient.quantity = total.value; existingIngredient.unit = total.unit || void 0; if (existingIngredient.quantityParts) { existingIngredient.quantityParts.push( ...newIngredient.quantityParts ); } else { existingIngredient.quantityParts = newIngredient.quantityParts; } quantityPartIndex = existingIngredient.quantityParts.length - 1; } catch (e2) { if (e2 instanceof IncompatibleUnitsError || e2 instanceof CannotAddTextValueError) { return { ingredientIndex: ingredients.push(newIngredient) - 1, quantityPartIndex: 0 }; } } } return { ingredientIndex: indexFind, quantityPartIndex }; } return { ingredientIndex: ingredients.push(newIngredient) - 1, quantityPartIndex: 0 }; } function findAndUpsertCookware(cookware, newCookware, isReference) { const { name, quantity } = newCookware; if (isReference) { const index = cookware.findIndex( (i2) => i2.name.toLowerCase() === name.toLowerCase() ); if (index === -1) { throw new Error( `Referenced cookware "${name}" not found. A referenced cookware must be declared before being referenced with '&'.` ); } const existingCookware = cookware[index]; for (const flag of newCookware.flags) { if (!existingCookware.flags.includes(flag)) { throw new ReferencedItemCannotBeRedefinedError( "cookware", existingCookware.name, flag ); } } let quantityPartIndex = void 0; if (quantity !== void 0) { if (!existingCookware.quantity) { existingCookware.quantity = quantity; existingCookware.quantityParts = newCookware.quantityParts; quantityPartIndex = 0; } else { try { existingCookware.quantity = addQuantityValues( existingCookware.quantity, quantity ); if (!existingCookware.quantityParts) { existingCookware.quantityParts = newCookware.quantityParts; quantityPartIndex = 0; } else { quantityPartIndex = existingCookware.quantityParts.push( ...newCookware.quantityParts ) - 1; } } catch (e2) { if (e2 instanceof CannotAddTextValueError) { return { cookwareIndex: cookware.push(newCookware) - 1, quantityPartIndex: 0 }; } } } } return { cookwareIndex: index, quantityPartIndex }; } return { cookwareIndex: cookware.push(newCookware) - 1, quantityPartIndex: quantity ? 0 : void 0 }; } var parseFixedValue = (input_str) => { if (!numberLikeRegex.test(input_str)) { return { type: "text", value: input_str }; } const s = input_str.trim().replace(",", "."); if (s.includes("/")) { const parts = s.split("/"); const num = Number(parts[0]); const den = Number(parts[1]); return { type: "fraction", num, den }; } return { type: "decimal", value: Number(s) }; }; function parseQuantityInput(input_str) { const clean_str = String(input_str).trim(); if (rangeRegex.test(clean_str)) { const range_parts = clean_str.split("-"); const min = parseFixedValue(range_parts[0].trim()); const max = parseFixedValue(range_parts[1].trim()); return { type: "range", min, max }; } return { type: "fixed", value: parseFixedValue(clean_str) }; } function parseSimpleMetaVar(content, varName) { const varMatch = content.match( new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m") ); return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0; } function parseScalingMetaVar(content, varName) { const varMatch = content.match(scalingMetaValueRegex(varName)); if (!varMatch) return void 0; if (isNaN(Number(varMatch[2]?.trim()))) { throw new Error("Scaling variables should be numbers"); } return [Number(varMatch[2]?.trim()), varMatch[1].trim()]; } function parseListMetaVar(content, varName) { const listMatch = content.match( new RegExp( `^${varName}:\\s*(?:\\[([^\\]]*)\\]|((?:\\r?\\n\\s*-\\s*.+)+))`, "m" ) ); if (!listMatch) return void 0; if (listMatch[1] !== void 0) { return listMatch[1].split(",").map((tag) => tag.trim()); } else if (listMatch[2]) { return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim()); } } function extractMetadata(content) { const metadata = {}; let servings = void 0; const metadataContent = content.match(metadataRegex)?.[1]; if (!metadataContent) { return { metadata }; } for (const metaVar of [ "title", "source", "source.name", "source.url", "author", "source.author", "prep time", "time.prep", "cook time", "time.cook", "time required", "time", "duration", "locale", "introduction", "description", "course", "category", "diet", "cuisine", "difficulty", "image", "picture" ]) { const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar); if (stringMetaValue) metadata[metaVar] = stringMetaValue; } for (const metaVar of ["serves", "yield", "servings"]) { const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar); if (scalingMetaValue && scalingMetaValue[1]) { metadata[metaVar] = scalingMetaValue[1]; servings = scalingMetaValue[0]; } } for (const metaVar of ["tags", "images", "pictures"]) { const listMetaValue = parseListMetaVar(metadataContent, metaVar); if (listMetaValue) metadata[metaVar] = listMetaValue; } return { metadata, servings }; } // src/classes/recipe.ts var Recipe = class _Recipe { /** * Creates a new Recipe instance. * @param content - The recipe content to parse. */ constructor(content) { /** * The parsed recipe metadata. */ __publicField(this, "metadata", {}); /** * The parsed recipe ingredients. */ __publicField(this, "ingredients", []); /** * The parsed recipe sections. */ __publicField(this, "sections", []); /** * The parsed recipe cookware. */ __publicField(this, "cookware", []); /** * The parsed recipe timers. */ __publicField(this, "timers", []); /** * The parsed recipe servings. Used for scaling. Parsed from one of * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves} * metadata fields. * * @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods */ __publicField(this, "servings"); if (content) { this.parse(content); } } /** * Parses a recipe from a string. * @param content - The recipe content to parse. */ parse(content) { const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/); const { metadata, servings } = extractMetadata(content); this.metadata = metadata; this.servings = servings; let blankLineBefore = true; let section = new Section(); const items = []; let note = ""; let inNote = false; for (const line of cleanContent) { if (line.trim().length === 0) { flushPendingItems(section, items); note = flushPendingNote(section, note); blankLineBefore = true; inNote = false; continue; } if (line.startsWith("=")) { flushPendingItems(section, items); note = flushPendingNote(section, note); if (this.sections.length === 0 && section.isBlank()) { section.name = line.substring(1).trim(); } else { if (!section.isBlank()) { this.sections.push(section); } section = new Section(line.substring(1).trim()); } blankLineBefore = true; inNote = false; continue; } if (blankLineBefore && line.startsWith(">")) { flushPendingItems(section, items); note = flushPendingNote(section, note); note += line.substring(1).trim(); inNote = true; blankLineBefore = false; continue; } if (inNote) { if (line.startsWith(">")) { note += " " + line.substring(1).trim(); } else { note += " " + line.trim(); } blankLineBefore = false; continue; } note = flushPendingNote(section, note); let cursor = 0; for (const match of line.matchAll(tokensRegex)) { const idx = match.index; if (idx > cursor) { items.push({ type: "text", value: line.slice(cursor, idx) }); } const groups = match.groups; if (groups.mIngredientName || groups.sIngredientName) { let name = groups.mIngredientName || groups.sIngredientName; const scalableQuantity = (groups.mIngredientQuantityModifier || groups.sIngredientQuantityModifier) !== "="; const quantityRaw = groups.mIngredientQuantity || groups.sIngredientQuantity; const unit = groups.mIngredientUnit || groups.sIngredientUnit; const preparation = groups.mIngredientPreparation || groups.sIngredientPreparation; const modifiers = groups.mIngredientModifiers || groups.sIngredientModifiers; const reference = modifiers !== void 0 && modifiers.includes("&"); const flags = []; if (modifiers !== void 0 && modifiers.includes("?")) { flags.push("optional"); } if (modifiers !== void 0 && modifiers.includes("-")) { flags.push("hidden"); } if (modifiers !== void 0 && modifiers.includes("@") || groups.mIngredientRecipeAnchor || groups.sIngredientRecipeAnchor) { flags.push("recipe"); } let extras = void 0; if (flags.includes("recipe")) { extras = { path: `${name}.cook` }; name = name.substring(name.lastIndexOf("/") + 1); } const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0; const aliasMatch = name.match(ingredientAliasRegex); let listName, displayName; if (aliasMatch && aliasMatch.groups.ingredientListName.trim().length > 0 && aliasMatch.groups.ingredientDisplayName.trim().length > 0) { listName = aliasMatch.groups.ingredientListName.trim(); displayName = aliasMatch.groups.ingredientDisplayName.trim(); } else { listName = name; displayName = name; } const newIngredient = { name: listName, quantity, quantityParts: quantity ? [ { value: quantity, unit, scalable: scalableQuantity } ] : void 0, unit, preparation, flags }; if (extras) { newIngredient.extras = extras; } const idxsInList = findAndUpsertIngredient( this.ingredients, newIngredient, reference ); const newItem = { type: "ingredient", index: idxsInList.ingredientIndex, displayName }; if (idxsInList.quantityPartIndex !== void 0) { newItem.quantityPartIndex = idxsInList.quantityPartIndex; } items.push(newItem); } else if (groups.mCookwareName || groups.sCookwareName) { const name = groups.mCookwareName || groups.sCookwareName; const modifiers = groups.mCookwareModifiers || groups.sCookwareModifiers; const quantityRaw = groups.mCookwareQuantity || groups.sCookwareQuantity; const reference = modifiers !== void 0 && modifiers.includes("&"); const flags = []; if (modifiers !== void 0 && modifiers.includes("?")) { flags.push("optional"); } if (modifiers !== void 0 && modifiers.includes("-")) { flags.push("hidden"); } const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0; const idxsInList = findAndUpsertCookware( this.cookware, { name, quantity, quantityParts: quantity ? [quantity] : void 0, flags }, reference ); items.push({ type: "cookware", index: idxsInList.cookwareIndex, quantityPartIndex: idxsInList.quantityPartIndex }); } else { const durationStr = groups.timerQuantity.trim(); const unit = (groups.timerUnit || "").trim(); if (!unit) { throw new Error("Timer missing unit"); } const name = groups.timerName || void 0; const duration = parseQuantityInput(durationStr); const timerObj = { name, duration, unit }; items.push({ type: "timer", index: this.timers.push(timerObj) - 1 }); } cursor = idx + match[0].length; } if (cursor < line.length) { items.push({ type: "text", value: line.slice(cursor) }); } blankLineBefore = false; } flushPendingItems(section, items); note = flushPendingNote(section, note); if (!section.isBlank()) { this.sections.push(section); } } /** * Scales the recipe to a new number of servings. In practice, it calls * {@link Recipe.scaleBy | scaleBy} with a factor corresponding to the ratio between `newServings` * and the recipe's {@link Recipe.servings | servings} value. * @param newServings - The new number of servings. * @returns A new Recipe instance with the scaled ingredients. * @throws `Error` if the recipe does not contains an initial {@link Recipe.servings | servings} value */ scaleTo(newServings) { const originalServings = this.getServings(); if (originalServings === void 0 || originalServings === 0) { throw new Error("Error scaling recipe: no initial servings value set"); } const factor = newServings / originalServings; return this.scaleBy(factor); } /** * Scales the recipe by a factor. * @param factor - The factor to scale the recipe by. * @returns A new Recipe instance with the scaled ingredients. */ scaleBy(factor) { const newRecipe = this.clone(); const originalServings = newRecipe.getServings(); if (originalServings === void 0 || originalServings === 0) { throw new Error("Error scaling recipe: no initial servings value set"); } newRecipe.ingredients = newRecipe.ingredients.map((ingredient) => { if (ingredient.quantityParts) { ingredient.quantityParts = ingredient.quantityParts.map( (quantityPart) => { if (quantityPart.value.type === "fixed" && quantityPart.value.value.type === "text") { return quantityPart; } return { ...quantityPart, value: multiplyQuantityValue( quantityPart.value, quantityPart.scalable ? factor : 1 ) }; } ); if (ingredient.quantityParts.length === 1) { ingredient.quantity = ingredient.quantityParts[0].value; ingredient.unit = ingredient.quantityParts[0].unit; } else { const totalQuantity = ingredient.quantityParts.reduce( (acc, val) => addQuantities(acc, { value: val.value, unit: val.unit }), { value: getDefaultQuantityValue() } ); ingredient.quantity = totalQuantity.value; ingredient.unit = totalQuantity.unit; } } return ingredient; }).filter((ingredient) => ingredient.quantity !== null); newRecipe.servings = originalServings * factor; if (newRecipe.metadata.servings && this.metadata.servings) { if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) { const servingsValue = parseFloat( String(this.metadata.servings).replace(",", ".") ); newRecipe.metadata.servings = String(servingsValue * factor); } } if (newRecipe.metadata.yield && this.metadata.yield) { if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) { const yieldValue = parseFloat( String(this.metadata.yield).replace(",", ".") ); newRecipe.metadata.yield = String(yieldValue * factor); } } if (newRecipe.metadata.serves && this.metadata.serves) { if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) { const servesValue = parseFloat( String(this.metadata.serves).replace(",", ".") ); newRecipe.metadata.serves = String(servesValue * factor); } } return newRecipe; } /** * Gets the number of servings for the recipe. * @private * @returns The number of servings, or undefined if not set. */ getServings() { if (this.servings) { return this.servings; } return void 0; } /** * Clones the recipe. * @returns A new Recipe instance with the same properties. */ clone() { const newRecipe = new _Recipe(); newRecipe.metadata = JSON.parse(JSON.stringify(this.metadata)); newRecipe.ingredients = JSON.parse( JSON.stringify(this.ingredients) ); newRecipe.sections = JSON.parse(JSON.stringify(this.sections)); newRecipe.cookware = JSON.parse( JSON.stringify(this.cookware) ); newRecipe.timers = JSON.parse(JSON.stringify(this.timers)); newRecipe.servings = this.servings; return newRecipe; } }; // src/classes/shopping_list.ts var ShoppingList = class { /** * Creates a new ShoppingList instance * @param category_config_str - The category configuration to parse. */ constructor(category_config_str) { /** * The ingredients in the shopping list. */ __publicField(this, "ingredients", []); /** * The recipes in the shopping list. */ __publicField(this, "recipes", []); /** * The category configuration for the shopping list. */ __publicField(this, "category_config"); /** * The categorized ingredients in the shopping list. */ __publicField(this, "categories"); if (category_config_str) { this.set_category_config(category_config_str); } } calculate_ingredients() { this.ingredients = []; for (const { recipe, factor } of this.recipes) { const scaledRecipe = factor === 1 ? recipe : recipe.scaleBy(factor); for (const ingredient of scaledRecipe.ingredients) { if (ingredient.flags && ingredient.flags.includes("hidden")) { continue; } const existingIngredient = this.ingredients.find( (i2) => i2.name === ingredient.name ); let addSeparate = false; try { if (existingIngredient && ingredient.quantity) { if (existingIngredient.quantity) { const newQuantity = addQuantities( { value: existingIngredient.quantity, unit: existingIngredient.unit ?? "" }, { value: ingredient.quantity, unit: ingredient.unit ?? "" } ); existingIngredient.quantity = newQuantity.value; if (newQuantity.unit) { existingIngredient.unit = newQuantity.unit; } } else { existingIngredient.quantity = ingredient.quantity; if (ingredient.unit) { existingIngredient.unit = ingredient.unit; } } } } catch { addSeparate = true; } if (!existingIngredient || addSeparate) { const newIngredient = { name: ingredient.name }; if (ingredient.quantity) { newIngredient.quantity = ingredient.quantity; } if (ingredient.unit) { newIngredient.unit = ingredient.unit; } this.ingredients.push(newIngredient); } } } } /** * Adds a recipe to the shopping list, then automatically * recalculates the quantities and recategorize the ingredients. * @param recipe - The recipe to add. * @param factor - The factor to scale the recipe by. */ add_recipe(recipe, factor = 1) { this.recipes.push({ recipe, factor }); this.calculate_ingredients(); this.categorize(); } /** * Removes a recipe from the shopping list, then automatically * recalculates the quantities and recategorize the ingredients.s * @param index - The index of the recipe to remove. */ remove_recipe(index) { if (index < 0 || index >= this.recipes.length) { throw new Error("Index out of bounds"); } this.recipes.splice(index, 1); this.calculate_ingredients(); this.categorize(); } /** * Sets the category configuration for the shopping list * and automatically categorize current ingredients from the list. * @param config - The category configuration to parse. */ set_category_config(config) { if (typeof config === "string") this.category_config = new CategoryConfig(config); else if (config instanceof CategoryConfig) this.category_config = config; else throw new Error("Invalid category configuration"); this.categorize(); } /** * Categorizes the ingredients in the shopping list * Will use the category config if any, otherwise all ingredients will be placed in the "other" category */ categorize() { if (!this.category_config) { this.categories = { other: this.ingredients }; return; } const categories = { other: [] }; for (const category of this.category_config.categories) { categories[category.name] = []; } for (const ingredient of this.ingredients) { let found = false; for (const category of this.category_config.categories) { for (const categoryIngredient of category.ingredients) { if (categoryIngredient.aliases.includes(ingredient.name)) { categories[category.name].push(ingredient); found = true; break; } } if (found) { break; } } if (!found) { categories.other.push(ingredient); } } this.categories = categories; } }; export { CategoryConfig, Recipe, Section, ShoppingList }; /* v8 ignore else -- @preserve */ /* v8 ignore else -- expliciting error types -- @preserve */ /* v8 ignore else -- expliciting error type -- @preserve */ /* v8 ignore else -- only set unit if it is given -- @preserve */ //# sourceMappingURL=index.js.map