@tmlmt/cooklang-parser
Version:
Cooklang parsers and utilities
1,349 lines (1,340 loc) • 46.3 kB
JavaScript
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