@tmlmt/cooklang-parser 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,13 +91,13 @@ console.log(shoppingList.categories);
91
91
 
92
92
  I plan to further develop features depending on the needs I will encounter in using this library in a practical application. The current todo includes:
93
93
 
94
- - Range values for ingredient quantities and timers
94
+ - Ingredients aliases. See issue tmlmt/cooklang-parser#5
95
95
 
96
96
  ## Test coverage
97
97
 
98
98
  This project includes a test setup aimed at eventually ensuring reliable parsing/scaling of as many recipe use cases as possible.
99
99
 
100
- You can run the tests yourself by cloning the repository and running `pnpm test`. To see the coverage report, run `pnpm run test:coverage`.
100
+ You can run the tests yourself by cloning the repository and running `pnpm test`. To see the coverage report, run `pnpm test:coverage`.
101
101
 
102
102
  If you find any issue with your own examples of recipes, feel free to open an Issue and if you want to help fix it, to submit a Pull Request.
103
103
 
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ var index_exports = {};
24
24
  __export(index_exports, {
25
25
  AisleConfig: () => AisleConfig,
26
26
  Recipe: () => Recipe,
27
+ Section: () => Section,
27
28
  ShoppingList: () => ShoppingList
28
29
  });
29
30
  module.exports = __toCommonJS(index_exports);
@@ -91,23 +92,233 @@ var AisleConfig = class {
91
92
 
92
93
  // src/classes/section.ts
93
94
  var Section = class {
95
+ /**
96
+ * Creates an instance of Section.
97
+ * @param name - The name of the section. Defaults to an empty string.
98
+ */
94
99
  constructor(name = "") {
100
+ /** The name of the section. Can be an empty string for the default (first) section. */
95
101
  __publicField(this, "name");
102
+ /** An array of steps and notes that make up the content of the section. */
96
103
  __publicField(this, "content", []);
97
104
  this.name = name;
98
105
  }
106
+ /**
107
+ * Checks if the section is blank (has no name and no content).
108
+ * Used during recipe parsing
109
+ * @returns `true` if the section is blank, otherwise `false`.
110
+ */
99
111
  isBlank() {
100
112
  return this.name === "" && this.content.length === 0;
101
113
  }
102
114
  };
103
115
 
116
+ // node_modules/.pnpm/human-regex@2.1.5_patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460/node_modules/human-regex/dist/human-regex.esm.js
117
+ var t = /* @__PURE__ */ new Map();
118
+ var r = { GLOBAL: "g", NON_SENSITIVE: "i", MULTILINE: "m", DOT_ALL: "s", UNICODE: "u", STICKY: "y" };
119
+ var e = Object.freeze({ digit: "0-9", lowercaseLetter: "a-z", uppercaseLetter: "A-Z", letter: "a-zA-Z", alphanumeric: "a-zA-Z0-9", anyCharacter: "." });
120
+ var n = Object.freeze({ zeroOrMore: "*", oneOrMore: "+", optional: "?" });
121
+ var a = class {
122
+ constructor() {
123
+ this.parts = [], this.flags = /* @__PURE__ */ new Set();
124
+ }
125
+ digit() {
126
+ return this.add("\\d");
127
+ }
128
+ special() {
129
+ return this.add("(?=.*[!@#$%^&*])");
130
+ }
131
+ word() {
132
+ return this.add("\\w");
133
+ }
134
+ whitespace() {
135
+ return this.add("\\s");
136
+ }
137
+ nonWhitespace() {
138
+ return this.add("\\S");
139
+ }
140
+ literal(r2) {
141
+ return this.add((function(r3) {
142
+ t.has(r3) || t.set(r3, r3.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
143
+ return t.get(r3);
144
+ })(r2));
145
+ }
146
+ or() {
147
+ return this.add("|");
148
+ }
149
+ range(t2) {
150
+ const r2 = e[t2];
151
+ if (!r2) throw new Error(`Unknown range: ${t2}`);
152
+ return this.add(`[${r2}]`);
153
+ }
154
+ notRange(t2) {
155
+ const r2 = e[t2];
156
+ if (!r2) throw new Error(`Unknown range: ${t2}`);
157
+ return this.add(`[^${r2}]`);
158
+ }
159
+ anyOf(t2) {
160
+ return this.add(`[${t2}]`);
161
+ }
162
+ notAnyOf(t2) {
163
+ return this.add(`[^${t2}]`);
164
+ }
165
+ lazy() {
166
+ const t2 = this.parts.pop();
167
+ if (!t2) throw new Error("No quantifier to make lazy");
168
+ return this.add(`${t2}?`);
169
+ }
170
+ letter() {
171
+ return this.add("[a-zA-Z]");
172
+ }
173
+ anyCharacter() {
174
+ return this.add(".");
175
+ }
176
+ newline() {
177
+ return this.add("(?:\\r\\n|\\r|\\n)");
178
+ }
179
+ negativeLookahead(t2) {
180
+ return this.add(`(?!${t2})`);
181
+ }
182
+ positiveLookahead(t2) {
183
+ return this.add(`(?=${t2})`);
184
+ }
185
+ positiveLookbehind(t2) {
186
+ return this.add(`(?<=${t2})`);
187
+ }
188
+ negativeLookbehind(t2) {
189
+ return this.add(`(?<!${t2})`);
190
+ }
191
+ hasSpecialCharacter() {
192
+ return this.add("(?=.*[!@#$%^&*])");
193
+ }
194
+ hasDigit() {
195
+ return this.add("(?=.*\\d)");
196
+ }
197
+ hasLetter() {
198
+ return this.add("(?=.*[a-zA-Z])");
199
+ }
200
+ optional() {
201
+ return this.add(n.optional);
202
+ }
203
+ exactly(t2) {
204
+ return this.add(`{${t2}}`);
205
+ }
206
+ atLeast(t2) {
207
+ return this.add(`{${t2},}`);
208
+ }
209
+ atMost(t2) {
210
+ return this.add(`{0,${t2}}`);
211
+ }
212
+ between(t2, r2) {
213
+ return this.add(`{${t2},${r2}}`);
214
+ }
215
+ oneOrMore() {
216
+ return this.add(n.oneOrMore);
217
+ }
218
+ zeroOrMore() {
219
+ return this.add(n.zeroOrMore);
220
+ }
221
+ startNamedGroup(t2) {
222
+ return this.add(`(?<${t2}>`);
223
+ }
224
+ startGroup() {
225
+ return this.add("(?:");
226
+ }
227
+ startCaptureGroup() {
228
+ return this.add("(");
229
+ }
230
+ wordBoundary() {
231
+ return this.add("\\b");
232
+ }
233
+ nonWordBoundary() {
234
+ return this.add("\\B");
235
+ }
236
+ endGroup() {
237
+ return this.add(")");
238
+ }
239
+ startAnchor() {
240
+ return this.add("^");
241
+ }
242
+ endAnchor() {
243
+ return this.add("$");
244
+ }
245
+ global() {
246
+ return this.flags.add(r.GLOBAL), this;
247
+ }
248
+ nonSensitive() {
249
+ return this.flags.add(r.NON_SENSITIVE), this;
250
+ }
251
+ multiline() {
252
+ return this.flags.add(r.MULTILINE), this;
253
+ }
254
+ dotAll() {
255
+ return this.flags.add(r.DOT_ALL), this;
256
+ }
257
+ sticky() {
258
+ return this.flags.add(r.STICKY), this;
259
+ }
260
+ unicodeChar(t2) {
261
+ this.flags.add(r.UNICODE);
262
+ const e2 = /* @__PURE__ */ new Set(["u", "l", "t", "m", "o"]);
263
+ if (void 0 !== t2 && !e2.has(t2)) throw new Error(`Invalid Unicode letter variant: ${t2}`);
264
+ return this.add(`\\p{L${null != t2 ? t2 : ""}}`);
265
+ }
266
+ unicodeDigit() {
267
+ return this.flags.add(r.UNICODE), this.add("\\p{N}");
268
+ }
269
+ unicodePunctuation() {
270
+ return this.flags.add(r.UNICODE), this.add("\\p{P}");
271
+ }
272
+ unicodeSymbol() {
273
+ return this.flags.add(r.UNICODE), this.add("\\p{S}");
274
+ }
275
+ repeat(t2) {
276
+ if (0 === this.parts.length) throw new Error("No pattern to repeat");
277
+ const r2 = this.parts.pop();
278
+ return this.parts.push(`(${r2}){${t2}}`), this;
279
+ }
280
+ ipv4Octet() {
281
+ return this.add("(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)");
282
+ }
283
+ protocol() {
284
+ return this.add("https?://");
285
+ }
286
+ www() {
287
+ return this.add("(www\\.)?");
288
+ }
289
+ tld() {
290
+ return this.add("(com|org|net)");
291
+ }
292
+ path() {
293
+ return this.add("(/\\w+)*");
294
+ }
295
+ add(t2) {
296
+ return this.parts.push(t2), this;
297
+ }
298
+ toString() {
299
+ return this.parts.join("");
300
+ }
301
+ toRegExp() {
302
+ return new RegExp(this.toString(), [...this.flags].join(""));
303
+ }
304
+ };
305
+ var d = () => new a();
306
+ var i = (() => {
307
+ const t2 = (t3) => {
308
+ const r2 = t3().toRegExp();
309
+ return () => new RegExp(r2.source, r2.flags);
310
+ };
311
+ 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())) };
312
+ })();
313
+
104
314
  // src/regex.ts
105
- var metadataRegex = /---\n(.*?)\n---/s;
106
- var multiwordIngredient = /@(?<mIngredientModifier>[@\-&+*!?])?(?<mIngredientName>(?:[^\s@#~\[\]{(.,;:!?]+(?:\s+[^\s@#~\[\]{(.,;:!?]+)+))(?=\s*(?:\{|\}|\(\s*[^)]*\s*\)))(?:\{(?<mIngredientQuantity>\p{No}|(?:\p{Nd}+(?:[.,\/][\p{Nd}]+)?))?(?:%(?<mIngredientUnits>[^}]+?))?\})?(?:\((?<mIngredientPreparation>[^)]*?)\))?/gu;
107
- var singleWordIngredient = /@(?<sIngredientModifier>[@\-&+*!?])?(?<sIngredientName>[^\s@#~\[\]{(.,;:!?]+)(?:\{(?:(?<sIngredientQuantity>\p{No}|(?:\p{Nd}+(?:[.,\/][\p{Nd}]+)?))(?:%(?<sIngredientUnits>[^}]+?))?)?\})?(?:\((?<sIngredientPreparation>[^)]*?)\))?/gu;
108
- var multiwordCookware = /#(?<mCookwareModifier>[\-&+*!?])?(?<mCookwareName>(?:[^\s@#~\[\]{(.,;:!?]+(?:\s+[^\s@#~\[\]{(.,;:!?]+)+))(?=\s*(?:\{|\}|\(\s*[^)]*\s*\)))\{(?<mCookwareQuantity>.*?)\}/;
109
- var singleWordCookware = /#(?<sCookwareModifier>[\-&+*!?])?(?<sCookwareName>[^\s@#~\[\]{(.,;:!?]+)(?:\{(?<sCookwareQuantity>.*?)\})?/u;
110
- var timer = /~(?<timerName>.*?)(?:\{(?<timerQuantity>.*?)(?:%(?<timerUnits>.+?))?\})/;
315
+ var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
316
+ var nonWordChar = "\\s@#~\\[\\]{(.,;:!?";
317
+ var multiwordIngredient = d().literal("@").startNamedGroup("mIngredientModifier").anyOf("@\\-&?").endGroup().optional().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").startGroup().literal("{").startNamedGroup("mIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("mIngredientUnits").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("mIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
318
+ var singleWordIngredient = d().literal("@").startNamedGroup("sIngredientModifier").anyOf("@\\-&?").endGroup().optional().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).oneOrMore().endGroup().startGroup().literal("{").startNamedGroup("sIngredientQuantity").notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("sIngredientUnits").notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("sIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
319
+ var multiwordCookware = d().literal("#").startNamedGroup("mCookwareModifier").anyOf("\\-&?").endGroup().optional().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();
320
+ var singleWordCookware = d().literal("#").startNamedGroup("sCookwareModifier").anyOf("\\-&?").endGroup().optional().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).oneOrMore().endGroup().startGroup().literal("{").startNamedGroup("sCookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp();
321
+ var timer = d().literal("~").startNamedGroup("timerName").anyCharacter().zeroOrMore().lazy().endGroup().literal("{").startNamedGroup("timerQuantity").anyCharacter().oneOrMore().lazy().endGroup().startGroup().literal("%").startNamedGroup("timerUnits").anyCharacter().oneOrMore().lazy().endGroup().endGroup().optional().literal("}").toRegExp();
111
322
  var tokensRegex = new RegExp(
112
323
  [
113
324
  multiwordIngredient,
@@ -115,11 +326,14 @@ var tokensRegex = new RegExp(
115
326
  multiwordCookware,
116
327
  singleWordCookware,
117
328
  timer
118
- ].map((r) => r.source).join("|"),
329
+ ].map((r2) => r2.source).join("|"),
119
330
  "gu"
120
331
  );
121
- var commentRegex = /--.*/g;
122
- var blockCommentRegex = /\s*\[\-.*?\-\]\s*/g;
332
+ var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
333
+ var blockCommentRegex = d().whitespace().zeroOrMore().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
334
+ 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();
335
+ var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().literal("-").startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
336
+ var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
123
337
 
124
338
  // src/units.ts
125
339
  var units = [
@@ -237,46 +451,148 @@ for (const unit of units) {
237
451
  function normalizeUnit(unit) {
238
452
  return unitMap.get(unit.toLowerCase().trim());
239
453
  }
454
+ var CannotAddTextValueError = class extends Error {
455
+ constructor() {
456
+ super("Cannot add a quantity with a text value.");
457
+ this.name = "CannotAddTextValueError";
458
+ }
459
+ };
460
+ var IncompatibleUnitsError = class extends Error {
461
+ constructor(unit1, unit2) {
462
+ super(
463
+ `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`
464
+ );
465
+ this.name = "IncompatibleUnitsError";
466
+ }
467
+ };
468
+ function gcd(a2, b) {
469
+ return b === 0 ? a2 : gcd(b, a2 % b);
470
+ }
471
+ function simplifyFraction(num, den) {
472
+ if (den === 0) {
473
+ throw new Error("Denominator cannot be zero.");
474
+ }
475
+ const commonDivisor = gcd(Math.abs(num), Math.abs(den));
476
+ let simplifiedNum = num / commonDivisor;
477
+ let simplifiedDen = den / commonDivisor;
478
+ if (simplifiedDen < 0) {
479
+ simplifiedNum = -simplifiedNum;
480
+ simplifiedDen = -simplifiedDen;
481
+ }
482
+ if (simplifiedDen === 1) {
483
+ return { type: "decimal", value: simplifiedNum };
484
+ } else {
485
+ return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
486
+ }
487
+ }
488
+ function multiplyNumericValue(v, factor) {
489
+ if (v.type === "decimal") {
490
+ return { type: "decimal", value: v.value * factor };
491
+ }
492
+ return simplifyFraction(v.num * factor, v.den);
493
+ }
494
+ function addNumericValues(val1, val2) {
495
+ let num1;
496
+ let den1;
497
+ let num2;
498
+ let den2;
499
+ if (val1.type === "decimal") {
500
+ num1 = val1.value;
501
+ den1 = 1;
502
+ } else {
503
+ num1 = val1.num;
504
+ den1 = val1.den;
505
+ }
506
+ if (val2.type === "decimal") {
507
+ num2 = val2.value;
508
+ den2 = 1;
509
+ } else {
510
+ num2 = val2.num;
511
+ den2 = val2.den;
512
+ }
513
+ if (val1.type === "fraction" && val2.type === "fraction") {
514
+ const commonDen = den1 * den2;
515
+ const sumNum = num1 * den2 + num2 * den1;
516
+ return simplifyFraction(sumNum, commonDen);
517
+ } else {
518
+ return { type: "decimal", value: num1 / den1 + num2 / den2 };
519
+ }
520
+ }
521
+ var toRoundedDecimal = (v) => {
522
+ const value = v.type === "decimal" ? v.value : v.num / v.den;
523
+ return { type: "decimal", value: Math.floor(value * 100) / 100 };
524
+ };
525
+ function multiplyQuantityValue(value, factor) {
526
+ if (value.type === "fixed") {
527
+ return {
528
+ type: "fixed",
529
+ value: toRoundedDecimal(
530
+ multiplyNumericValue(
531
+ value.value,
532
+ factor
533
+ )
534
+ )
535
+ };
536
+ }
537
+ return {
538
+ type: "range",
539
+ min: toRoundedDecimal(
540
+ multiplyNumericValue(value.min, factor)
541
+ ),
542
+ max: toRoundedDecimal(
543
+ multiplyNumericValue(value.max, factor)
544
+ )
545
+ };
546
+ }
547
+ var convertQuantityValue = (value, def, targetDef) => {
548
+ if (def.name === targetDef.name) return value;
549
+ const factor = def.toBase / targetDef.toBase;
550
+ return multiplyQuantityValue(value, factor);
551
+ };
240
552
  function addQuantities(q1, q2) {
553
+ const v1 = q1.value;
554
+ const v2 = q2.value;
555
+ if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
556
+ throw new CannotAddTextValueError();
557
+ }
241
558
  const unit1Def = normalizeUnit(q1.unit);
242
559
  const unit2Def = normalizeUnit(q2.unit);
243
- if (isNaN(Number(q1.value))) {
244
- throw new Error(
245
- `Cannot add quantity to string-quantified value: ${q1.value}`
560
+ const addQuantityValuesAndSetUnit = (val1, val2, unit) => {
561
+ if (val1.type === "fixed" && val2.type === "fixed") {
562
+ const res = addNumericValues(
563
+ val1.value,
564
+ val2.value
565
+ );
566
+ return { value: { type: "fixed", value: res }, unit };
567
+ }
568
+ const r1 = val1.type === "range" ? val1 : { type: "range", min: val1.value, max: val1.value };
569
+ const r2 = val2.type === "range" ? val2 : { type: "range", min: val2.value, max: val2.value };
570
+ const newMin = addNumericValues(
571
+ r1.min,
572
+ r2.min
246
573
  );
247
- }
248
- if (isNaN(Number(q2.value))) {
249
- throw new Error(
250
- `Cannot add quantity to string-quantified value: ${q2.value}`
574
+ const newMax = addNumericValues(
575
+ r1.max,
576
+ r2.max
251
577
  );
252
- }
578
+ return { value: { type: "range", min: newMin, max: newMax }, unit };
579
+ };
253
580
  if (q1.unit === "" && unit2Def) {
254
- return {
255
- value: Math.round((q1.value + q2.value) * 100) / 100,
256
- unit: q2.unit
257
- };
581
+ return addQuantityValuesAndSetUnit(v1, v2, q2.unit);
258
582
  }
259
583
  if (q2.unit === "" && unit1Def) {
260
- return {
261
- value: Math.round((q1.value + q2.value) * 100) / 100,
262
- unit: q1.unit
263
- };
584
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
264
585
  }
265
586
  if (q1.unit.toLowerCase() === q2.unit.toLowerCase()) {
266
- return {
267
- value: Math.round((q1.value + q2.value) * 100) / 100,
268
- unit: q1.unit
269
- };
587
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
270
588
  }
271
589
  if (unit1Def && unit2Def) {
272
590
  if (unit1Def.type !== unit2Def.type) {
273
- throw new Error(
274
- `Cannot add quantities of different types: ${unit1Def.type} (${q1.unit}) and ${unit2Def.type} (${q2.unit})`
591
+ throw new IncompatibleUnitsError(
592
+ `${unit1Def.type} (${q1.unit})`,
593
+ `${unit2Def.type} (${q2.unit})`
275
594
  );
276
595
  }
277
- const baseValue1 = q1.value * unit1Def.toBase;
278
- const baseValue2 = q2.value * unit2Def.toBase;
279
- const totalBaseValue = baseValue1 + baseValue2;
280
596
  let targetUnitDef;
281
597
  if (unit1Def.system !== unit2Def.system) {
282
598
  const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
@@ -286,15 +602,15 @@ function addQuantities(q1, q2) {
286
602
  } else {
287
603
  targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
288
604
  }
289
- const finalValue = totalBaseValue / targetUnitDef.toBase;
290
- return {
291
- value: Math.round(finalValue * 100) / 100,
292
- unit: targetUnitDef.name
293
- };
605
+ const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
606
+ const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
607
+ return addQuantityValuesAndSetUnit(
608
+ convertedV1,
609
+ convertedV2,
610
+ targetUnitDef.name
611
+ );
294
612
  }
295
- throw new Error(
296
- `Cannot add quantities with incompatible or unknown units: ${q1.unit} and ${q2.unit}`
297
- );
613
+ throw new IncompatibleUnitsError(q1.unit, q2.unit);
298
614
  }
299
615
 
300
616
  // src/parser_helpers.ts
@@ -324,7 +640,7 @@ function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
324
640
  const { name, quantity, unit } = newIngredient;
325
641
  if (isReference) {
326
642
  const index = ingredients.findIndex(
327
- (i) => i.name.toLowerCase() === name.toLowerCase()
643
+ (i2) => i2.name.toLowerCase() === name.toLowerCase()
328
644
  );
329
645
  if (index === -1) {
330
646
  throw new Error(
@@ -334,13 +650,22 @@ function findAndUpsertIngredient(ingredients, newIngredient, isReference) {
334
650
  const existingIngredient = ingredients[index];
335
651
  if (quantity !== void 0) {
336
652
  const currentQuantity = {
337
- value: existingIngredient.quantity ?? 0,
653
+ value: existingIngredient.quantity ?? {
654
+ type: "fixed",
655
+ value: { type: "decimal", value: 0 }
656
+ },
338
657
  unit: existingIngredient.unit ?? ""
339
658
  };
340
659
  const newQuantity = { value: quantity, unit: unit ?? "" };
341
- const total = addQuantities(currentQuantity, newQuantity);
342
- existingIngredient.quantity = total.value;
343
- existingIngredient.unit = total.unit || void 0;
660
+ try {
661
+ const total = addQuantities(currentQuantity, newQuantity);
662
+ existingIngredient.quantity = total.value;
663
+ existingIngredient.unit = total.unit || void 0;
664
+ } catch (e2) {
665
+ if (e2 instanceof IncompatibleUnitsError || e2 instanceof CannotAddTextValueError) {
666
+ return ingredients.push(newIngredient) - 1;
667
+ }
668
+ }
344
669
  }
345
670
  return index;
346
671
  }
@@ -350,7 +675,7 @@ function findAndUpsertCookware(cookware, newCookware, isReference) {
350
675
  const { name } = newCookware;
351
676
  if (isReference) {
352
677
  const index = cookware.findIndex(
353
- (i) => i.name.toLowerCase() === name.toLowerCase()
678
+ (i2) => i2.name.toLowerCase() === name.toLowerCase()
354
679
  );
355
680
  if (index === -1) {
356
681
  throw new Error(
@@ -361,13 +686,28 @@ function findAndUpsertCookware(cookware, newCookware, isReference) {
361
686
  }
362
687
  return cookware.push(newCookware) - 1;
363
688
  }
364
- function parseNumber(input_str) {
365
- const clean_str = String(input_str).replace(",", ".");
366
- if (!clean_str.startsWith("/") && clean_str.includes("/")) {
367
- const [num, den] = clean_str.split("/").map(Number);
368
- return num / den;
689
+ var parseFixedValue = (input_str) => {
690
+ if (!numberLikeRegex.test(input_str)) {
691
+ return { type: "text", value: input_str };
369
692
  }
370
- return Number(clean_str);
693
+ const s = input_str.trim().replace(",", ".");
694
+ if (s.includes("/")) {
695
+ const parts = s.split("/");
696
+ const num = Number(parts[0]);
697
+ const den = Number(parts[1]);
698
+ return { type: "fraction", num, den };
699
+ }
700
+ return { type: "decimal", value: Number(s) };
701
+ };
702
+ function parseQuantityInput(input_str) {
703
+ const clean_str = String(input_str).trim();
704
+ if (rangeRegex.test(clean_str)) {
705
+ const range_parts = clean_str.split("-");
706
+ const min = parseFixedValue(range_parts[0].trim());
707
+ const max = parseFixedValue(range_parts[1].trim());
708
+ return { type: "range", min, max };
709
+ }
710
+ return { type: "fixed", value: parseFixedValue(clean_str) };
371
711
  }
372
712
  function parseSimpleMetaVar(content, varName) {
373
713
  const varMatch = content.match(
@@ -560,7 +900,7 @@ var Recipe = class _Recipe {
560
900
  const hidden = modifier === "-";
561
901
  const reference = modifier === "&";
562
902
  const isRecipe = modifier === "@";
563
- const quantity = quantityRaw ? parseNumber(quantityRaw) : void 0;
903
+ const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
564
904
  const idxInList = findAndUpsertIngredient(
565
905
  this.ingredients,
566
906
  {
@@ -587,12 +927,14 @@ var Recipe = class _Recipe {
587
927
  } else if (groups.mCookwareName || groups.sCookwareName) {
588
928
  const name = groups.mCookwareName || groups.sCookwareName;
589
929
  const modifier = groups.mCookwareModifier || groups.sCookwareModifier;
930
+ const quantityRaw = groups.mCookwareQuantity || groups.sCookwareQuantity;
590
931
  const optional = modifier === "?";
591
932
  const hidden = modifier === "-";
592
933
  const reference = modifier === "&";
934
+ const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
593
935
  const idxInList = findAndUpsertCookware(
594
936
  this.cookware,
595
- { name, optional, hidden },
937
+ { name, quantity, optional, hidden },
596
938
  reference
597
939
  );
598
940
  items.push({ type: "cookware", value: idxInList });
@@ -603,7 +945,7 @@ var Recipe = class _Recipe {
603
945
  throw new Error("Timer missing units");
604
946
  }
605
947
  const name = groups.timerName || void 0;
606
- const duration = parseNumber(durationStr);
948
+ const duration = parseQuantityInput(durationStr);
607
949
  const timerObj = {
608
950
  name,
609
951
  duration,
@@ -611,7 +953,7 @@ var Recipe = class _Recipe {
611
953
  };
612
954
  const idxInList = findOrPush(
613
955
  this.timers,
614
- (t) => t.name === timerObj.name && t.duration === timerObj.duration && t.unit === timerObj.unit,
956
+ (t2) => t2.name === timerObj.name && t2.duration === timerObj.duration && t2.unit === timerObj.unit,
615
957
  () => timerObj
616
958
  );
617
959
  items.push({ type: "timer", value: idxInList });
@@ -654,8 +996,11 @@ var Recipe = class _Recipe {
654
996
  throw new Error("Error scaling recipe: no initial servings value set");
655
997
  }
656
998
  newRecipe.ingredients = newRecipe.ingredients.map((ingredient) => {
657
- if (ingredient.quantity && !isNaN(Number(ingredient.quantity))) {
658
- ingredient.quantity *= factor;
999
+ if (ingredient.quantity && !(ingredient.quantity.type === "fixed" && ingredient.quantity.value.type === "text")) {
1000
+ ingredient.quantity = multiplyQuantityValue(
1001
+ ingredient.quantity,
1002
+ factor
1003
+ );
659
1004
  }
660
1005
  return ingredient;
661
1006
  }).filter((ingredient) => ingredient.quantity !== null);
@@ -680,6 +1025,11 @@ var Recipe = class _Recipe {
680
1025
  }
681
1026
  return newRecipe;
682
1027
  }
1028
+ /**
1029
+ * Gets the number of servings for the recipe.
1030
+ * @private
1031
+ * @returns The number of servings, or undefined if not set.
1032
+ */
683
1033
  getServings() {
684
1034
  if (this.servings) {
685
1035
  return this.servings;
@@ -742,7 +1092,7 @@ var ShoppingList = class {
742
1092
  continue;
743
1093
  }
744
1094
  const existingIngredient = this.ingredients.find(
745
- (i) => i.name === ingredient.name
1095
+ (i2) => i2.name === ingredient.name
746
1096
  );
747
1097
  let addSeparate = false;
748
1098
  try {
@@ -853,6 +1203,7 @@ var ShoppingList = class {
853
1203
  0 && (module.exports = {
854
1204
  AisleConfig,
855
1205
  Recipe,
1206
+ Section,
856
1207
  ShoppingList
857
1208
  });
858
1209
  //# sourceMappingURL=index.cjs.map