@recipe-book/core 0.1.0 → 0.1.2

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-VWVIBCTG.cjs → chunk-ISRDJR2O.cjs} +227 -179
  4. package/dist/chunk-ISRDJR2O.cjs.map +1 -0
  5. package/dist/errors.js +29 -1
  6. package/dist/errors.js.map +1 -1
  7. package/dist/format/duration.js.map +1 -1
  8. package/dist/format/ingredient.js.map +1 -1
  9. package/dist/format/number.js +1 -1
  10. package/dist/format/number.js.map +1 -1
  11. package/dist/format/options.js.map +1 -1
  12. package/dist/format/quantity.js.map +1 -1
  13. package/dist/format/servings.js.map +1 -1
  14. package/dist/format/temperature.js.map +1 -1
  15. package/dist/index.cjs +87 -74
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +71 -28
  18. package/dist/index.d.ts +71 -28
  19. package/dist/index.js +6 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/ingredient/parse.js +27 -22
  22. package/dist/ingredient/parse.js.map +1 -1
  23. package/dist/issues.js.map +1 -1
  24. package/dist/quantity/amount.js +11 -0
  25. package/dist/quantity/amount.js.map +1 -1
  26. package/dist/quantity/parse.js +58 -81
  27. package/dist/quantity/parse.js.map +1 -1
  28. package/dist/quantity/scale.js +5 -2
  29. package/dist/quantity/scale.js.map +1 -1
  30. package/dist/recipe/ids.js.map +1 -1
  31. package/dist/recipe/parse.js +80 -60
  32. package/dist/recipe/parse.js.map +1 -1
  33. package/dist/recipe/schema.js +8 -6
  34. package/dist/recipe/schema.js.map +1 -1
  35. package/dist/resolve.js +6 -1
  36. package/dist/resolve.js.map +1 -1
  37. package/dist/schema-org/find.js +46 -13
  38. package/dist/schema-org/find.js.map +1 -1
  39. package/dist/schema-org/from-json-ld.js +134 -71
  40. package/dist/schema-org/from-json-ld.js.map +1 -1
  41. package/dist/schema-org/text.js.map +1 -1
  42. package/dist/schema-org/to-json-ld.js +12 -15
  43. package/dist/schema-org/to-json-ld.js.map +1 -1
  44. package/dist/schema-org.cjs +239 -147
  45. package/dist/schema-org.cjs.map +1 -1
  46. package/dist/schema-org.d.cts +35 -21
  47. package/dist/schema-org.d.ts +35 -21
  48. package/dist/steps/segment.js.map +1 -1
  49. package/dist/steps/temperature.js +13 -13
  50. package/dist/steps/temperature.js.map +1 -1
  51. package/dist/{types-DSMf45H5.d.cts → types-CKIoWsz8.d.cts} +25 -7
  52. package/dist/{types-DSMf45H5.d.ts → types-CKIoWsz8.d.ts} +25 -7
  53. package/dist/units/convert.js +16 -8
  54. package/dist/units/convert.js.map +1 -1
  55. package/dist/units/temperature.js +7 -1
  56. package/dist/units/temperature.js.map +1 -1
  57. package/dist/validation.js +1 -1
  58. package/dist/validation.js.map +1 -1
  59. package/package.json +1 -1
  60. package/dist/chunk-VWVIBCTG.cjs.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Q as Quantity, P as ParseResult, R as Recipe, U as UnitAlias, S as System, I as Ingredient, a as Servings, Y as Yield, b as Step, A as Amount, c as Range, d as UnitId, e as UnitIdOf, f as UnitDefinition } from './types-DSMf45H5.js';
2
- export { C as CustomTime, D as Dimension, E as Equipment, g as IngredientSection, N as Note, h as Nutrition, i as RecipeBookError, j as RecipeBookErrorCode, k as RecipeIssue, l as RecipeIssueCode, m as RecipeValidationError, n as StepSection, T as Times, V as ValidationErrorCode, o as isRecipeBookError, u as units } from './types-DSMf45H5.js';
1
+ import { Q as Quantity, P as ParseResult, R as Recipe, U as UnitAlias, S as System, I as Ingredient, a as Servings, Y as Yield, b as Step, A as Amount, c as Range, d as UnitId, e as UnitIdOf, f as UnitDefinition } from './types-CKIoWsz8.js';
2
+ export { C as CustomTime, D as Dimension, E as Equipment, g as IngredientSection, N as Note, h as Nutrition, i as ParsedRecipe, j as RecipeBookError, k as RecipeBookErrorCode, l as RecipeIssue, m as RecipeIssueCode, n as RecipeValidationError, o as StepSection, T as Times, V as ValidationErrorCode, p as isRecipeBookError, q as isRecipeValidationError, u as units } from './types-CKIoWsz8.js';
3
3
 
4
4
  /**
5
5
  * An ingredient read from a line of text. It has no `id`, so pass it through
@@ -22,11 +22,11 @@ type IngredientLine = {
22
22
  * Reads a leading amount (`2`, `1–2`, `a`, `one`) and unit, and a second
23
23
  * measure in parentheses that hold nothing but measures, or after a slash or
24
24
  * semicolon, as `equivalents`. With no amount, a unit before "of" reads as 1
25
- * when it starts the line, after any leading "optional" and a space or colon,
26
- * and is written in letters only, so `'pinch of salt'` gets
27
- * `quantity: { amount: 1, unit: 'pinch' }`, but `'big pinch of salt'`,
28
- * `'tsp. of salt'`, `'fl oz of water'` and `'Optional - pinch of salt'` get
29
- * none. With no
25
+ * when it starts the line, after any leading "optional" and any space or
26
+ * colon, or "(optional)" and any space, and after any size word such as
27
+ * "big", so `'tsp. of salt'` gets `quantity: { amount: 1, unit: 'tsp' }` and
28
+ * `'big pinch of salt'` gets `quantity: { amount: 1, unit: 'pinch' }` and
29
+ * `note: 'big'`, but `'Optional - pinch of salt'` gets none. With no
30
30
  * leading amount or unit, the first measure in parentheses that hold nothing
31
31
  * but measures is the `quantity` instead, so `'Butter (100g)'` gets
32
32
  * `quantity: { amount: 100, unit: 'g' }`. Measures after a leading "optional"
@@ -37,6 +37,17 @@ type IngredientLine = {
37
37
  * In parentheses, any other sum (across dimensions, in count units such as
38
38
  * "pinch", or of three measures) makes every measure in them part of the note
39
39
  * (`'1 cup (8 oz + 1 tbsp) milk'`).
40
+ * With no leading amount or unit, an amount and unit after the name and a
41
+ * colon are read when nothing else follows but a note after a comma, so
42
+ * `'nagaimo: 160 g, grated'` gets `name: 'nagaimo'`,
43
+ * `quantity: { amount: 160, unit: 'g' }` and `note: 'grated'`. A name with a
44
+ * digit, or that starts with the word "for", "to", "a", "an", "yield",
45
+ * "yields" or "total", isn't read that way, and neither is one that is just
46
+ * "makes", "serves", "serving", "servings", "serving size", "protein",
47
+ * "carbs", "carbohydrate", "carbohydrates", "fiber", "fibre", "sodium",
48
+ * "cholesterol", "saturated fat" or "fat". So `'For the sauce: 1 cup'` and
49
+ * `'Protein: 12 g'` keep the whole line as the name, but
50
+ * `'Protein powder: 30 g'` gets `name: 'Protein powder'`.
40
51
  * It also reads a note after the first comma outside parentheses that doesn't
41
52
  * follow "boneless" or "skinless", phrases such as "to taste", and "optional"
42
53
  * at the start of the line or of text in parentheses (the innermost when they
@@ -102,7 +113,7 @@ declare function parseIngredientLine(text: string): ParseResult<IngredientLine>;
102
113
  * console.error(result.error.message)
103
114
  * // "Pancakes" has 1 problem:
104
115
  * // ✖ ingredients[0].quantity Unknown unit "cupz". Did you mean "cups"?
105
- * // See https://recipe-book.siraj.page/errors/invalid_recipe
116
+ * // See https://recipe-book.siraj.page/errors/invalid_recipe/
106
117
  * }
107
118
  */
108
119
  declare function parseRecipe(input: unknown): ParseResult<Recipe>;
@@ -148,7 +159,7 @@ type ParseRecipeOptions = {
148
159
  *
149
160
  * const result = parseQuantity('2 cupz')
150
161
  * if (!result.success) {
151
- * result.error.message // '"2 cupz" has 1 problem:\n ✖ (root) Unknown unit "cupz". Did you mean "cups"?\nSee https://recipe-book.siraj.page/errors/invalid_quantity'
162
+ * result.error.message // '"2 cupz" has 1 problem:\n ✖ (root) Unknown unit "cupz". Did you mean "cups"?\nSee https://recipe-book.siraj.page/errors/invalid_quantity/'
152
163
  * }
153
164
  */
154
165
  declare function parseQuantity(text: string): ParseResult<Quantity>;
@@ -445,7 +456,7 @@ type QuantityOrigin = 'authored' | 'equivalent' | 'converted';
445
456
  /** An ingredient, scaled and expressed in the display system. */
446
457
  type ResolvedIngredient = {
447
458
  id: string;
448
- /** The ingredient as authored, unscaled. */
459
+ /** The ingredient as authored, unscaled, not a copy; treat it as read-only. */
449
460
  ingredient: Ingredient;
450
461
  /**
451
462
  * Absent for unquantified ingredients, such as "salt, to taste". May be the
@@ -457,6 +468,7 @@ type ResolvedIngredient = {
457
468
  };
458
469
  /** What {@link resolveRecipe} returns: the recipe at a scale, in one system, unrounded. */
459
470
  type ResolvedRecipe = {
471
+ /** The recipe passed in, not a copy; treat it as read-only. */
460
472
  recipe: Recipe;
461
473
  /**
462
474
  * The `scale` option, else `servings` divided by the scaling base. Always
@@ -464,16 +476,17 @@ type ResolvedRecipe = {
464
476
  */
465
477
  scale: number;
466
478
  /**
467
- * The recipe's servings at this scale. At 1×, the authored servings. When
468
- * the `servings` option sets a scale other than 1, exactly that number with
469
- * no `max`; with `scale`, both ends of a range scale.
479
+ * The recipe's servings at this scale. At a scale within 1e-9 of 1, the
480
+ * authored servings. When the `servings` option sets any other scale,
481
+ * exactly that number with no `max`; with `scale`, both ends of a range
482
+ * scale.
470
483
  */
471
484
  servings?: Servings | undefined;
472
485
  /**
473
- * The recipe's yield at this scale. At 1×, the authored yield. When the
474
- * yield is the scaling base and the `servings` option sets a scale other
475
- * than 1, exactly that number with no `max`; otherwise both ends of a range
476
- * scale.
486
+ * The recipe's yield at this scale. At a scale within 1e-9 of 1, the
487
+ * authored yield. When the yield is the scaling base and the `servings`
488
+ * option sets any other scale, exactly that number with no `max`; otherwise
489
+ * both ends of a range scale.
477
490
  */
478
491
  yield?: Yield | undefined;
479
492
  system: System;
@@ -500,9 +513,9 @@ type ResolvedRecipe = {
500
513
  type ResolveRecipeOptions = {
501
514
  /**
502
515
  * Scales by `servings / base`, where the base is the recipe's servings
503
- * amount, else its yield amount, else 1. Defaults to the base. A value that
504
- * would scale the recipe by less than 1e-6 or more than 1e6, or isn't a
505
- * number, also uses the default.
516
+ * amount, else its yield amount, else 1. Defaults to the base. A value
517
+ * outside a millionth to a million times the base, or that isn't a number,
518
+ * also uses the default.
506
519
  */
507
520
  servings?: number | undefined;
508
521
  /**
@@ -553,6 +566,8 @@ declare function resolveRecipe(recipe: Recipe, options?: ResolveRecipeOptions):
553
566
  * 1e-6 to 1e6, so a huge scale can overflow an amount to `Infinity`, which
554
567
  * the format functions reject.
555
568
  *
569
+ * An error thrown by a getter or proxy in the input or options passes through.
570
+ *
556
571
  * @throws {RecipeBookError} `invalid_argument` when `scale` is not a finite
557
572
  * number greater than 0, or `system` is not `'metric'` or `'us'`.
558
573
  *
@@ -569,11 +584,15 @@ type SameAmount<A extends Amount> = A extends number ? number : Range;
569
584
  * Multiplies a quantity by `factor`. Both ends of a range scale, the unit is
570
585
  * kept, and nothing is rounded.
571
586
  *
587
+ * An error thrown by a getter or proxy in the input passes through.
588
+ *
572
589
  * @throws {RecipeBookError} `invalid_argument` when `factor` is not a finite
573
590
  * number greater than 0, `quantity` isn't a quantity object, the amount is
574
591
  * not a finite number greater than 0 or a range of them, a range's `min`
575
592
  * isn't less than its `max`, or the quantity has `max` set, as a `Servings`
576
593
  * or `Yield` has. `resolveRecipe` scales those. The unit isn't checked.
594
+ * Also thrown when an end times `factor` overflows to `Infinity` or
595
+ * underflows to 0: past about 1.8e308, or below about 5e-324.
577
596
  *
578
597
  * @example
579
598
  * scaleQuantity({ amount: 1.5, unit: 'cup' }, 2) // { amount: 3, unit: 'cup' }
@@ -591,11 +610,13 @@ declare function scaleQuantity<A extends Amount>(quantity: Quantity & {
591
610
  * range convert. Nothing is rounded, so the amount can be off in its last
592
611
  * digit: 5 tbsp is 14.999999999999998 tsp.
593
612
  *
613
+ * An error thrown by a getter or proxy in the input passes through.
614
+ *
594
615
  * @throws {RecipeBookError} `incompatible_units` across dimensions (cup to g),
595
616
  * between a count unit and a different unit, or for a bare count. Check with
596
617
  * {@link canConvert} first when the units come from data.
597
618
  * @throws {RecipeBookError} `unknown_unit` when either unit is not a unit id.
598
- * @throws {RecipeBookError} `invalid_argument` when `quantity` isn't a quantity object, the amount is not a finite number greater than 0 or a range of them, a range's `min` isn't less than its `max`, or the quantity has `max` set, as a `Servings` or `Yield` has.
619
+ * @throws {RecipeBookError} `invalid_argument` when `quantity` isn't a quantity object, the amount is not a finite number greater than 0 or a range of them, a range's `min` isn't less than its `max`, the quantity has `max` set, as a `Servings` or `Yield` has, or the result overflows to `Infinity` or underflows to 0, which only an amount above about 1e300 or below about 1e-300 can do.
599
620
  *
600
621
  * @example
601
622
  * convert({ amount: 1, unit: 'cup' }, 'ml') // { amount: 236.5882365, unit: 'ml' }
@@ -627,7 +648,9 @@ declare function canConvert(from: UnitId | undefined, to: UnitId): boolean;
627
648
  * (tsp and tbsp are native to both), when it is a count unit, or when it has
628
649
  * no unit.
629
650
  *
630
- * @throws {RecipeBookError} `invalid_argument` for an unknown system, an invalid amount, or a quantity with `max` set, as a `Servings` or `Yield` has.
651
+ * An error thrown by a getter or proxy in the input passes through.
652
+ *
653
+ * @throws {RecipeBookError} `invalid_argument` for an unknown system, an invalid amount, a quantity with `max` set, as a `Servings` or `Yield` has, or a result that overflows to `Infinity` or underflows to 0, which only an amount above about 1e300 or below about 1e-300 can do.
631
654
  * @throws {RecipeBookError} `unknown_unit` when the unit is not a unit id.
632
655
  *
633
656
  * @example
@@ -685,7 +708,9 @@ type Temperature = {
685
708
  * so the value can be off in its last digits: −18 °C is
686
709
  * −0.3999999999999986 °F.
687
710
  *
688
- * @throws {RecipeBookError} `invalid_argument` for a non-finite value or an unknown unit.
711
+ * An error thrown by a getter or proxy in the input passes through.
712
+ *
713
+ * @throws {RecipeBookError} `invalid_argument` for a non-finite value, an unknown unit, or a result that overflows to `Infinity` or `-Infinity`, which only a value beyond about ±2e307 can do.
689
714
  *
690
715
  * @example
691
716
  * convertTemperature({ value: 350, unit: 'F' }, 'C') // { value: 176.66…, unit: 'C' }
@@ -699,11 +724,13 @@ declare function convertTemperature(temperature: Temperature, to: TemperatureUni
699
724
  */
700
725
  type FormatOptions = {
701
726
  /**
702
- * BCP 47 language tag. Default `'en-US'`. Labels and plurals are English in
703
- * 0.1; only numbers follow the locale, and they are never grouped. Numbers
704
- * in other languages come from the runtime's `Intl` data, so the server and
705
- * client need the same locale support to render the same text. A locale the
706
- * runtime doesn't support falls back to English.
727
+ * A language tag that `Intl` accepts, such as `'en-US'`, the default. Labels
728
+ * and plurals are English in 0.1; only numbers follow the locale, and they
729
+ * are never grouped. Numbers in other languages come from the runtime's
730
+ * `Intl` data, so the server and client need the same locale support to
731
+ * render the same text. A locale the runtime doesn't support falls back to
732
+ * English, but a string `Intl` rejects throws. `Intl` rejects `'en_US'`, and
733
+ * some BCP 47 tags too, such as `'i-klingon'` and `'x-private'`.
707
734
  */
708
735
  locale?: string | undefined;
709
736
  /** `'short'` (`tbsp`) or `'long'` (`tablespoons`). Default `'short'`. */
@@ -759,6 +786,8 @@ type QuantityPart = {
759
786
  * through `Intl`. Other tags, including English ones with extensions such as
760
787
  * `'en-US-u-nu-arab'`, read the runtime's `Intl` data.
761
788
  *
789
+ * An error thrown by a getter or proxy in the input or options passes through.
790
+ *
762
791
  * @throws {RecipeBookError} `invalid_argument` for an invalid quantity or option, or a quantity with `max` set, as a `Servings` or `Yield` has (print those with `formatServings` and `formatYield`); `unknown_unit` for an unknown unit id.
763
792
  *
764
793
  * @example
@@ -772,6 +801,8 @@ declare function formatQuantity(quantity: Quantity, options?: FormatQuantityOpti
772
801
  * Formats a quantity as parts, so a UI can style the amount and unit
773
802
  * separately without parsing the string.
774
803
  *
804
+ * An error thrown by a getter or proxy in the input or options passes through.
805
+ *
775
806
  * @throws {RecipeBookError} The same errors as {@link formatQuantity}, including `invalid_argument` for a quantity with `max` set.
776
807
  *
777
808
  * @example
@@ -807,6 +838,8 @@ type FormattedIngredient = {
807
838
  * (U+00A0) after any bare count, `1 egg` included, and a regular space after
808
839
  * a unit.
809
840
  *
841
+ * An error thrown by a getter or proxy in the input or options passes through.
842
+ *
810
843
  * @throws {RecipeBookError} `invalid_argument` when `item` is not `{ ingredient, quantity? }` or the ingredient's `plural` or `note` is not a string; otherwise the same errors as `formatQuantity`, including `invalid_argument` for a quantity with `max` set.
811
844
  *
812
845
  * @example
@@ -828,6 +861,8 @@ declare function formatIngredient(item: {
828
861
  *
829
862
  * Scale with `resolveRecipe`, and print with `formatServings` and `formatYield`, not `formatQuantity`.
830
863
  *
864
+ * An error thrown by a getter or proxy in the input or options passes through.
865
+ *
831
866
  * @throws {RecipeBookError} `invalid_argument` for a value that isn't `{ amount, max? }` with `max` above `amount`, or for an invalid option.
832
867
  *
833
868
  * @example
@@ -850,6 +885,8 @@ declare function formatServings(servings: Servings, options?: FormatQuantityOpti
850
885
  *
851
886
  * Scale with `resolveRecipe`, and print with `formatServings` and `formatYield`, not `formatQuantity`.
852
887
  *
888
+ * An error thrown by a getter or proxy in the input or options passes through.
889
+ *
853
890
  * @throws {RecipeBookError} `invalid_argument` for a value that isn't `{ amount, max?, unit, plural? }` with `max` above `amount` and string words, or for an invalid option.
854
891
  *
855
892
  * @example
@@ -878,6 +915,8 @@ type FormatTemperatureOptions = FormatOptions & {
878
915
  * 176.67 °C reads "180 °C" and 356 °F reads "350 °F". `'none'` writes up to
879
916
  * three decimals.
880
917
  *
918
+ * An error thrown by a getter or proxy in the input or options passes through.
919
+ *
881
920
  * @throws {RecipeBookError} `invalid_argument` for an invalid temperature or option.
882
921
  *
883
922
  * @example
@@ -895,6 +934,8 @@ declare function formatTemperature(temperature: Temperature, options?: FormatTem
895
934
  * and seconds, leaving out zero parts. Each number and unit is joined by a
896
935
  * no-break space (U+00A0).
897
936
  *
937
+ * An error thrown by a getter or proxy in the options passes through.
938
+ *
898
939
  * @throws {RecipeBookError} `invalid_argument` when `minutes` is not a finite number, 0 or more, or for an invalid option.
899
940
  *
900
941
  * @example
@@ -962,6 +1003,8 @@ type SegmentStepTextOptions = {
962
1003
  * Splits step text into plain text and the temperatures in it. Joining every
963
1004
  * segment's `text` gives back the input exactly.
964
1005
  *
1006
+ * An error thrown by a getter or proxy in the options passes through.
1007
+ *
965
1008
  * @throws {RecipeBookError} `invalid_argument` for text that isn't a string or an unknown `assumeTemperatureUnit`.
966
1009
  *
967
1010
  * @example
package/dist/index.js CHANGED
@@ -15,7 +15,11 @@ import { formatServings, formatYield } from "./format/servings.js";
15
15
  import { formatTemperature } from "./format/temperature.js";
16
16
  import { formatDuration } from "./format/duration.js";
17
17
  import { segmentStepText } from "./steps/segment.js";
18
- import { isRecipeBookError, RecipeBookError } from "./errors.js";
18
+ import {
19
+ isRecipeBookError,
20
+ isRecipeValidationError,
21
+ RecipeBookError
22
+ } from "./errors.js";
19
23
  import { RecipeValidationError } from "./validation.js";
20
24
  export {
21
25
  RecipeBookError,
@@ -34,6 +38,7 @@ export {
34
38
  formatYield,
35
39
  getUnit,
36
40
  isRecipeBookError,
41
+ isRecipeValidationError,
37
42
  parseIngredientLine,
38
43
  parseQuantity,
39
44
  parseRecipe,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { parseRecipe } from './recipe/parse'\nexport { parseQuantity } from './quantity/parse'\nexport { parseIngredientLine } from './ingredient/parse'\nexport { createRecipeSchema, recipeSchema } from './recipe/schema'\nexport { resolveIngredient, resolveRecipe } from './resolve'\nexport { scaleQuantity } from './quantity/scale'\nexport { canConvert, convert, toSystem } from './units/convert'\nexport { findUnit, getUnit } from './units/lookup'\nexport { units } from './units/registry'\nexport { convertTemperature } from './units/temperature'\nexport { formatQuantity, formatQuantityToParts } from './format/quantity'\nexport { formatIngredient } from './format/ingredient'\nexport { formatServings, formatYield } from './format/servings'\nexport { formatTemperature } from './format/temperature'\nexport { formatDuration } from './format/duration'\nexport { segmentStepText } from './steps/segment'\nexport { isRecipeBookError, RecipeBookError } from './errors'\nexport { RecipeValidationError } from './validation'\n\nexport type {\n CustomTime,\n Equipment,\n Ingredient,\n IngredientSection,\n Note,\n Nutrition,\n Recipe,\n Servings,\n Step,\n StepSection,\n Times,\n Yield,\n} from './recipe/types'\nexport type {\n CustomTimeInput,\n DurationInput,\n EquipmentInput,\n IngredientInput,\n IngredientSectionInput,\n NoteInput,\n NutrientInput,\n NutritionInput,\n QuantityInput,\n QuantityString,\n RecipeInput,\n ServingsInput,\n StepInput,\n StepSectionInput,\n TimesInput,\n YieldInput,\n} from './recipe/input'\nexport type {\n Dimension,\n System,\n UnitAlias,\n UnitDefinition,\n UnitId,\n UnitIdOf,\n} from './units/registry'\nexport type { Temperature, TemperatureUnit } from './units/temperature'\nexport type { Amount, Quantity, Range } from './quantity/types'\nexport type { IngredientLine } from './ingredient/parse'\nexport type { ParseRecipeOptions } from './recipe/parse'\nexport type { FormatOptions } from './format/options'\nexport type { FormatQuantityOptions, QuantityPart } from './format/quantity'\nexport type { FormatTemperatureOptions } from './format/temperature'\nexport type { FormattedIngredient } from './format/ingredient'\nexport type {\n SegmentStepTextOptions,\n StepSegment,\n TemperatureReading,\n TemperatureSegment,\n TextSegment,\n} from './steps/segment'\nexport type {\n QuantityOrigin,\n ResolvedIngredient,\n ResolvedRecipe,\n ResolveIngredientOptions,\n ResolveRecipeOptions,\n} from './resolve'\nexport type {\n ParseResult,\n RecipeBookErrorCode,\n RecipeIssue,\n RecipeIssueCode,\n ValidationErrorCode,\n} from './errors'\nexport type { RecipeSchema } from './recipe/schema'\nexport type { StandardSchemaV1, StandardTypedV1 } from './standard-schema'\n"],"mappings":";AAAA,SAAS,mBAAmB;AAC5B,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AACpC,SAAS,oBAAoB,oBAAoB;AACjD,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,qBAAqB;AAC9B,SAAS,YAAY,SAAS,gBAAgB;AAC9C,SAAS,UAAU,eAAe;AAClC,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,gBAAgB,6BAA6B;AACtD,SAAS,wBAAwB;AACjC,SAAS,gBAAgB,mBAAmB;AAC5C,SAAS,yBAAyB;AAClC,SAAS,sBAAsB;AAC/B,SAAS,uBAAuB;AAChC,SAAS,mBAAmB,uBAAuB;AACnD,SAAS,6BAA6B;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export { parseRecipe } from './recipe/parse'\nexport { parseQuantity } from './quantity/parse'\nexport { parseIngredientLine } from './ingredient/parse'\nexport { createRecipeSchema, recipeSchema } from './recipe/schema'\nexport { resolveIngredient, resolveRecipe } from './resolve'\nexport { scaleQuantity } from './quantity/scale'\nexport { canConvert, convert, toSystem } from './units/convert'\nexport { findUnit, getUnit } from './units/lookup'\nexport { units } from './units/registry'\nexport { convertTemperature } from './units/temperature'\nexport { formatQuantity, formatQuantityToParts } from './format/quantity'\nexport { formatIngredient } from './format/ingredient'\nexport { formatServings, formatYield } from './format/servings'\nexport { formatTemperature } from './format/temperature'\nexport { formatDuration } from './format/duration'\nexport { segmentStepText } from './steps/segment'\nexport {\n isRecipeBookError,\n isRecipeValidationError,\n RecipeBookError,\n} from './errors'\nexport { RecipeValidationError } from './validation'\n\nexport type {\n CustomTime,\n Equipment,\n Ingredient,\n IngredientSection,\n Note,\n Nutrition,\n ParsedRecipe,\n Recipe,\n Servings,\n Step,\n StepSection,\n Times,\n Yield,\n} from './recipe/types'\nexport type {\n CustomTimeInput,\n DurationInput,\n EquipmentInput,\n IngredientInput,\n IngredientSectionInput,\n NoteInput,\n NutrientInput,\n NutritionInput,\n QuantityInput,\n QuantityString,\n RecipeInput,\n ServingsInput,\n StepInput,\n StepSectionInput,\n TimesInput,\n YieldInput,\n} from './recipe/input'\nexport type {\n Dimension,\n System,\n UnitAlias,\n UnitDefinition,\n UnitId,\n UnitIdOf,\n} from './units/registry'\nexport type { Temperature, TemperatureUnit } from './units/temperature'\nexport type { Amount, Quantity, Range } from './quantity/types'\nexport type { IngredientLine } from './ingredient/parse'\nexport type { ParseRecipeOptions } from './recipe/parse'\nexport type { FormatOptions } from './format/options'\nexport type { FormatQuantityOptions, QuantityPart } from './format/quantity'\nexport type { FormatTemperatureOptions } from './format/temperature'\nexport type { FormattedIngredient } from './format/ingredient'\nexport type {\n SegmentStepTextOptions,\n StepSegment,\n TemperatureReading,\n TemperatureSegment,\n TextSegment,\n} from './steps/segment'\nexport type {\n QuantityOrigin,\n ResolvedIngredient,\n ResolvedRecipe,\n ResolveIngredientOptions,\n ResolveRecipeOptions,\n} from './resolve'\nexport type {\n ParseResult,\n RecipeBookErrorCode,\n RecipeIssue,\n RecipeIssueCode,\n ValidationErrorCode,\n} from './errors'\nexport type { RecipeSchema } from './recipe/schema'\nexport type { StandardSchemaV1, StandardTypedV1 } from './standard-schema'\n"],"mappings":";AAAA,SAAS,mBAAmB;AAC5B,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AACpC,SAAS,oBAAoB,oBAAoB;AACjD,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,qBAAqB;AAC9B,SAAS,YAAY,SAAS,gBAAgB;AAC9C,SAAS,UAAU,eAAe;AAClC,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,gBAAgB,6BAA6B;AACtD,SAAS,wBAAwB;AACjC,SAAS,gBAAgB,mBAAmB;AAC5C,SAAS,yBAAyB;AAClC,SAAS,sBAAsB;AAC/B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,6BAA6B;","names":[]}
@@ -16,16 +16,16 @@ var ABOUT = /(about|approx(?:imately|\.)?) /iy;
16
16
  var PHRASE = /((?:an? )?(?:handful|few|couple|little|splash|drizzle|knob|squeeze|sprinkle|dollop))(?: of)? /iy;
17
17
  var HYPHENATED_MIXED = /^(\d+)-(?=(\d+)[/⁄](\d+)(?![\d/⁄]))/;
18
18
  var AMOUNT_CHARS = /(?:an? )?(?:[\d .,/⁄¼-¾⅐-⅞–—-]|to )*/iy;
19
- var UNIT_OF = /([a-z]+) of /iy;
20
19
  var TIMES = / ?[x×] ?(?=\d)/iy;
21
- var UNREAD = / ?(?:(?:or|to|and|plus|half)\b|[-–—/+&]|\d+(?:\.\d+)?(?= |$))/iy;
20
+ var UNREAD = / ?(?:(?:or|to|and|plus|half)(?![\p{L}\p{M}\d_])|[-–—/+&]|\d+(?:\.\d+)?(?= |$))/iuy;
22
21
  var UNREAD_AFTER_UNIT = / (?:or|to|[-–—&])(?= )|[-–—+&](?=\d)/iy;
23
22
  var PAREN = / ?\(([^()]*)\)/y;
24
23
  var PARENS = / ?\(([^()]*)\)/g;
25
24
  var MODIFIER = / ?(heaping|heaped|level|scant|rounded|generous|good|small|medium|large|big)(?= )/iy;
26
- var JOIN = /(?: (plus|and|\+)(?![a-z]))? ?/iy;
25
+ var JOIN = /(?: (plus|and|\+)(?![\p{L}\p{M}]))? ?/iuy;
27
26
  var SEPARATOR = / ?[;/] ?/y;
28
27
  var OF = / ?of(?: (?:an?|the))?(?= |$)/iy;
28
+ var NAMED = /(?!(?:for|to|an?|yields?|total)(?![\p{L}\p{M}\d_])|(?:makes|serv(?:es|ings?)(?: size)?|protein|carbs|carbohydrates?|fiber|fibre|sodium|cholesterol|saturated fat|fat) ?:)([^\d:]+):/iuy;
29
29
  var PHRASE_NOTE = / (?=to taste|as needed|as required|plus more|plus extra|or more|or to taste|for (?:serving|garnish|dusting|greasing|frying|drizzling|decoration))/i;
30
30
  var OPTIONAL_NOTE = /(?:^|,) ?optional(?= ?(?:,|$))/gi;
31
31
  var OPTIONAL_PAREN = /^optional\b[,;:]? ?/i;
@@ -144,11 +144,22 @@ function readLine(line) {
144
144
  if (found) {
145
145
  ;
146
146
  [amount, i] = found;
147
- } else if (match = exec(UNIT_OF, line, i)) {
148
- const unit = findUnit(match[1]);
149
- if (unit) {
150
- quantity = { amount: 1, unit };
151
- i = UNIT_OF.lastIndex;
147
+ } else {
148
+ const modifier = exec(MODIFIER, line, i);
149
+ const unit = readUnit(line, modifier ? MODIFIER.lastIndex : i);
150
+ if (unit && exec(OF, line, unit[1])) {
151
+ if (modifier) {
152
+ addNote(modifier[1]);
153
+ }
154
+ quantity = { amount: 1, unit: unit[0] };
155
+ i = unit[1];
156
+ } else if (match = exec(NAMED, line, i)) {
157
+ const measure = readMeasure(line, NAMED.lastIndex, false);
158
+ if (measure && (line[measure[1]] ?? ",") === ",") {
159
+ quantity = measure[0];
160
+ line = match[1] + line.slice(measure[1]);
161
+ i = 0;
162
+ }
152
163
  }
153
164
  }
154
165
  }
@@ -283,7 +294,7 @@ function readUnit(text, start) {
283
294
  const { twoWordUnit, unit } = getPatterns();
284
295
  for (const pattern of [twoWordUnit, unit]) {
285
296
  const match = exec(pattern, text, start);
286
- const id = match && findUnit(match[2] ? `${match[1]} ${match[2]}` : match[1]);
297
+ const id = match && findUnit(match.slice(1).join(" "));
287
298
  if (id) {
288
299
  return [id, pattern.lastIndex];
289
300
  }
@@ -381,7 +392,13 @@ function countForms(name, many) {
381
392
  k = CONTAINER.test(words[0]) ? 0 : words.length - 1;
382
393
  }
383
394
  const word = words[k];
384
- const other = many ? singular(word) : pluralize(word);
395
+ const other = many ? word.replace(
396
+ /(?:(lea|hal|loa)ves|([rtdv])ies|(o|ch|sh|x|ss|chill?i)es|([^su])s)$/i,
397
+ (_, f, y, es, s) => f ? `${f}f` : y ? `${y}y` : es ?? s
398
+ ) : word.replace(
399
+ /(?:(lea|hal|loa)f|([^aeiou])y|(ch|sh|x|z|tomato|potato|mango|chill?i)|[a-rt-z])$/i,
400
+ (match, f, y, es) => f ? `${f}ves` : y ? `${y}ies` : es ? `${es}es` : `${match}s`
401
+ );
385
402
  if (other === word) {
386
403
  return [name, void 0];
387
404
  }
@@ -389,18 +406,6 @@ function countForms(name, many) {
389
406
  const swapped = words.join(" ");
390
407
  return many ? [swapped, name] : [name, swapped];
391
408
  }
392
- function singular(word) {
393
- return word.replace(
394
- /(?:(lea|hal|loa)ves|([rtdv])ies|(o|ch|sh|x|ss|chill?i)es|([^su])s)$/i,
395
- (_, f, y, es, s) => f ? `${f}f` : y ? `${y}y` : es ?? s
396
- );
397
- }
398
- function pluralize(word) {
399
- return word.replace(
400
- /(?:(lea|hal|loa)f|([^aeiou])y|(ch|sh|x|z|tomato|potato|mango|chill?i)|[a-rt-z])$/i,
401
- (match, f, y, es) => f ? `${f}ves` : y ? `${y}ies` : es ? `${es}es` : `${match}s`
402
- );
403
- }
404
409
  export {
405
410
  parseIngredientLine
406
411
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/ingredient/parse.ts"],"sourcesContent":["import {\n describeValue,\n type ParseResult,\n quote,\n type RecipeIssue,\n} from '../errors'\nimport { warning } from '../issues'\nimport { isPositive } from '../quantity/amount'\nimport { matchAmount, type Problem } from '../quantity/parse'\nimport type { Amount, Quantity } from '../quantity/types'\nimport { findUnit } from '../units/lookup'\nimport { units, type UnitId } from '../units/registry'\nimport { error, toResult } from '../validation'\n\nconst NUMBER_WORDS =\n 'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve'\n\n/** A space or dash between an amount and its unit, as in \"15-ounce\". */\nconst DASH = '[ –—-]'\n\n/**\n * An ingredient read from a line of text. It has no `id`, so pass it through\n * `parseRecipe` before resolving it.\n */\nexport type IngredientLine = {\n name: string\n plural?: string | undefined\n quantity?: Quantity | undefined\n equivalents?: Quantity[] | undefined\n note?: string | undefined\n optional?: boolean | undefined\n}\n\ntype Read<T> = [value: T, end: number]\n\nconst OPTIONAL = /(?:\\(optional\\)|optional\\b ?:?) ?/iy\nconst ABOUT = /(about|approx(?:imately|\\.)?) /iy\nconst PHRASE =\n /((?:an? )?(?:handful|few|couple|little|splash|drizzle|knob|squeeze|sprinkle|dollop))(?: of)? /iy\nconst HYPHENATED_MIXED = /^(\\d+)-(?=(\\d+)[/⁄](\\d+)(?![\\d/⁄]))/\n// Text an amount can be made of, so a measure reads only up to its unit.\nconst AMOUNT_CHARS = /(?:an? )?(?:[\\d .,/⁄¼-¾⅐-⅞–—-]|to )*/iy\nconst UNIT_OF = /([a-z]+) of /iy\nconst TIMES = / ?[x×] ?(?=\\d)/iy\n// Quantity text left after a bare count, as in \"2 or 3\" or \"1 000 g\".\n// A number joined to a word, as in \"2 9-inch pie crusts\", is a name.\nconst UNREAD = / ?(?:(?:or|to|and|plus|half)\\b|[-–—/+&]|\\d+(?:\\.\\d+)?(?= |$))/iy\n// A range or alternative after the unit, as in \"1 cup to 1 ¼ cups\",\n// \"1 lb or 450 g\", or \"100g-150g\".\nconst UNREAD_AFTER_UNIT = / (?:or|to|[-–—&])(?= )|[-–—+&](?=\\d)/iy\nconst PAREN = / ?\\(([^()]*)\\)/y\nconst PARENS = / ?\\(([^()]*)\\)/g\nconst MODIFIER =\n / ?(heaping|heaped|level|scant|rounded|generous|good|small|medium|large|big)(?= )/iy\nconst JOIN = /(?: (plus|and|\\+)(?![a-z]))? ?/iy\nconst SEPARATOR = / ?[;/] ?/y\nconst OF = / ?of(?: (?:an?|the))?(?= |$)/iy\nconst PHRASE_NOTE =\n / (?=to taste|as needed|as required|plus more|plus extra|or more|or to taste|for (?:serving|garnish|dusting|greasing|frying|drizzling|decoration))/i\nconst OPTIONAL_NOTE = /(?:^|,) ?optional(?= ?(?:,|$))/gi\nconst OPTIONAL_PAREN = /^optional\\b[,;:]? ?/i\nconst CONTAINER =\n /^(?:stalk|rib|ear|sheet|loa(?:f|ves)|knob|strip|bag|box|jar|bottle|packet|envelope|tin|block|ball|handful)(?:e?s)?$/i\nconst BONELESS = /(?:^|[^a-z])(?:bone|skin)less$/i\n\nlet patterns: ReturnType<typeof compilePatterns> | undefined\n\n// A bundler drops an unused regex literal but may keep a `new RegExp` call, so\n// the patterns built from strings compile on first use.\nfunction getPatterns() {\n return (patterns ??= compilePatterns())\n}\n\nfunction compilePatterns() {\n const word = String.raw`([a-z]+(?:-[a-z]+)?)\\.?`\n const end = String.raw`(?=[ ,;()/]|[-–—+&]\\d|$)`\n return {\n count: new RegExp(`(an?|${NUMBER_WORDS}) (?=\\\\S)`, 'iy'),\n unit: new RegExp(`${DASH}?${word}${end}`, 'iy'),\n twoWordUnit: new RegExp(`${DASH}?${word} ${word}${end}`, 'iy'),\n }\n}\n\n/**\n * Parses an ingredient line, such as `'1 ½ cups (190 g) flour, sifted'`, into\n * ingredient input. Never throws. Pass the result through `parseRecipe` before\n * resolving, or pass this function to `parseRecipe` as\n * `{ parseIngredientLine }` to put lines in recipe input.\n *\n * Reads a leading amount (`2`, `1–2`, `a`, `one`) and unit, and a second\n * measure in parentheses that hold nothing but measures, or after a slash or\n * semicolon, as `equivalents`. With no amount, a unit before \"of\" reads as 1\n * when it starts the line, after any leading \"optional\" and a space or colon,\n * and is written in letters only, so `'pinch of salt'` gets\n * `quantity: { amount: 1, unit: 'pinch' }`, but `'big pinch of salt'`,\n * `'tsp. of salt'`, `'fl oz of water'` and `'Optional - pinch of salt'` get\n * none. With no\n * leading amount or unit, the first measure in parentheses that hold nothing\n * but measures is the `quantity` instead, so `'Butter (100g)'` gets\n * `quantity: { amount: 100, unit: 'g' }`. Measures after a leading \"optional\"\n * in parentheses are read the same way, except straight after the amount,\n * where the first is dropped (`'2 (optional, 15 oz) cans beans'`). A measure\n * in the same dimension, mass or volume, after \"plus\", \"and\" or \"+\", or\n * straight after the first, is added to it (`'1 lb 2 oz flour'` is 1.125 lb).\n * In parentheses, any other sum (across dimensions, in count units such as\n * \"pinch\", or of three measures) makes every measure in them part of the note\n * (`'1 cup (8 oz + 1 tbsp) milk'`).\n * It also reads a note after the first comma outside parentheses that doesn't\n * follow \"boneless\" or \"skinless\", phrases such as \"to taste\", and \"optional\"\n * at the start of the line or of text in parentheses (the innermost when they\n * nest), or as its own comma-separated item after the first comma. After a\n * leading \"optional\" and any space or colon, or \"(optional)\" and any space,\n * the rest is read as if it were the whole line, so\n * `'Optional - 2 cups flour'` gets `optional: true` and\n * `name: '- 2 cups flour'` with no quantity. With a comma there, not even\n * \"optional\" is read: `'optional, 2 cups flour'` becomes\n * `{ name: 'optional, 2 cups flour' }` with a warning. Text in parentheses\n * before the first comma that isn't a measure or a leading \"optional\"\n * (only the innermost when they nest) is a note too, ahead of the text after\n * the comma, so `'1 onion (diced), peeled'` gets `note: 'diced, peeled'`. When\n * parentheses before the first comma nest, the outer ones stay in the name with\n * their text, so `'2 onions (diced (fine)), peeled'` gets\n * `name: 'onions (diced)'` and `note: 'fine, peeled'`, and\n * `'115 g butter ((1/2 cup))'` gets `name: 'butter ()'`.\n * A bare count gets a `plural` from the word before \"of\", a leading container\n * such as \"jars\", or else the last word. There's none when that word gets no\n * other form, as when it ends in parentheses (`'2 onions (diced (fine))'`),\n * has no plural ending after a count above 1 or a range (`'12 shrimp'`), or\n * ends in \"s\" after a count of 1 or less (`'1 eggs'`, `'½ onions'`). A\n * line it can't read becomes `{ name: line }` with a warning; only blank text\n * or a value that isn't a string is an error.\n *\n * @example\n * parseIngredientLine('1 ½ cups (190 g) flour, sifted').data\n * // { name: 'flour', quantity: { amount: 1.5, unit: 'cup' }, equivalents: [{ amount: 190, unit: 'g' }], note: 'sifted' }\n * parseIngredientLine('2 large eggs').data // { name: 'large egg', plural: 'large eggs', quantity: { amount: 2 } }\n */\nexport function parseIngredientLine(text: string): ParseResult<IngredientLine> {\n const issues: RecipeIssue[] = []\n\n if (typeof text !== 'string') {\n error(\n issues,\n 'invalid_type',\n [],\n `Expected ingredient text such as \"2 cups flour\", received ${describeValue(text)}.`,\n )\n return toResult<IngredientLine>('invalid_ingredient', undefined, issues)\n }\n\n // U+200B is invisible but isn't whitespace. U+200C and U+200D are kept,\n // because they join letters in some scripts and in emoji.\n const line = text.replace(/[\\s\\u200b]+/g, ' ').trim()\n\n if (line === '') {\n error(\n issues,\n 'missing_field',\n [],\n 'Add the ingredient text, e.g. \"2 cups flour\".',\n )\n return toResult<IngredientLine>(\n 'invalid_ingredient',\n undefined,\n issues,\n quote(text),\n )\n }\n\n let ingredient = readLine(line)\n\n if (isProblem(ingredient)) {\n warning(\n issues,\n ingredient.code,\n [],\n `${ingredient.message} The whole line is kept as the name.`,\n )\n ingredient = { name: line }\n }\n\n return toResult('invalid_ingredient', ingredient, issues)\n}\n\nfunction readLine(line: string): IngredientLine | Problem {\n const p = getPatterns()\n const notes: string[] = []\n const equivalents: Quantity[] = []\n let optional = false\n let quantity: Quantity | undefined\n let amount: Amount | undefined\n let unitText = ''\n let i = 0\n let match: RegExpExecArray | null\n\n const eat = (pattern: RegExp): RegExpExecArray | null => {\n const found = exec(pattern, line, i)\n if (found) {\n i = pattern.lastIndex\n }\n return found\n }\n\n const addNote = (text: string): void => {\n const note = /[^ ,](?:.*[^ ,])?/s.exec(text)\n if (note) {\n notes.push(note[0])\n }\n }\n\n const readParen = (content: string, keep: boolean): string => {\n const text = content.replace(OPTIONAL_PAREN, () => {\n optional = true\n return ''\n })\n const measures = text ? readMeasures(text) : undefined\n\n if (measures) {\n quantity ??= measures.shift()\n // A spread passes each measure as an argument, which overflows the stack\n // for a long list.\n for (const measure of measures) {\n equivalents.push(measure)\n }\n return ''\n }\n\n if (keep && text) {\n return ` (${text})`\n }\n\n addNote(text)\n return ''\n }\n\n if (eat(OPTIONAL)) {\n optional = true\n }\n\n match = exec(ABOUT, line, i)\n if (match && readAmount(line, ABOUT.lastIndex)) {\n addNote(match[1]!)\n i = ABOUT.lastIndex\n }\n\n if ((match = eat(PHRASE))) {\n addNote(match[1]!)\n } else if ((match = eat(p.count))) {\n amount = NUMBER_WORDS.split('|').indexOf(match[1]!.toLowerCase()) + 1 || 1\n } else {\n const found = readAmount(line, i, true)\n\n if (isProblem(found)) {\n return found\n }\n if (found) {\n ;[amount, i] = found\n } else if ((match = exec(UNIT_OF, line, i))) {\n const unit = findUnit(match[1]!)\n if (unit) {\n quantity = { amount: 1, unit }\n i = UNIT_OF.lastIndex\n }\n }\n }\n\n if (amount !== undefined) {\n let each: Quantity[] | undefined\n eat(TIMES)\n\n if ((match = eat(PAREN))) {\n each = readMeasures(match[1]!)\n if (!each) {\n readParen(match[1]!, false)\n }\n } else {\n const measure = readMeasure(line, i, false)\n if (measure) {\n each = [measure[0]]\n i = measure[1]\n }\n }\n\n const start = i\n const modifier = eat(MODIFIER)\n const unit = readUnit(line, i)\n\n if (unit) {\n if (modifier) {\n addNote(modifier[1]!)\n }\n unitText = line.slice(i, unit[1])\n quantity = { amount, unit: unit[0] }\n i = unit[1]\n\n const size =\n !each &&\n units[unit[0]].dimension === 'count' &&\n readMeasure(line, i, false)\n\n if (size) {\n equivalents.push(size[0])\n i = size[1]\n } else if (!each) {\n const extended = extend(line, [quantity, i])\n if (isProblem(extended)) {\n return extended\n }\n ;[quantity, i] = extended\n }\n\n while ((match = eat(SEPARATOR))) {\n const measure = readMeasure(line, i, true)\n if (!measure) {\n return noMeasure(quote(match[0].trim()), '100 g/3½ oz')\n }\n equivalents.push(measure[0])\n i = measure[1]\n }\n } else {\n i = start\n quantity = { amount }\n }\n\n if (exec(unit ? UNREAD_AFTER_UNIT : UNREAD, line, i)) {\n return {\n code: 'invalid_quantity',\n message: `Couldn't read the quantity in ${quote(line)}. Write one amount or range before the unit, e.g. \"2-3 eggs\" or \"2 ½ cups\".`,\n }\n }\n\n for (const item of each ?? []) {\n const total = combine(amount, item.amount, (a, b) => a * b)\n if (isProblem(total)) {\n return total\n }\n equivalents.push({ ...item, amount: total })\n }\n }\n\n const of = quantity !== undefined && eat(OF)\n\n const rest = line.slice(i).trim()\n const comma = topLevelComma(rest)\n let name = rest\n .slice(0, comma)\n .replace(PARENS, (_, content: string) => readParen(content, false))\n const phrase = PHRASE_NOTE.exec(name)\n\n if (phrase) {\n addNote(name.slice(phrase.index))\n name = name.slice(0, phrase.index)\n }\n\n addNote(\n rest\n .slice(comma + 1)\n .replace(PARENS, (_, content: string) => readParen(content, true))\n .replace(OPTIONAL_NOTE, () => {\n optional = true\n return ''\n }),\n )\n name = name.trim()\n\n if (!name) {\n if (\n of ||\n !unitText ||\n !quantity?.unit ||\n units[quantity.unit].dimension !== 'count'\n ) {\n return {\n code: 'missing_field',\n message: 'Add a name after the quantity, e.g. \"2 cups flour\".',\n }\n }\n name = unitText.replace(/^[ –—-]/, '')\n quantity = { amount: quantity.amount }\n }\n\n let plural: string | undefined\n if (quantity && !quantity.unit) {\n const many = typeof quantity.amount !== 'number' || quantity.amount > 1\n ;[name, plural] = countForms(name, many)\n }\n\n return {\n name,\n ...(plural !== undefined && { plural }),\n ...(quantity && { quantity }),\n ...(equivalents.length > 0 && { equivalents }),\n ...(notes.length > 0 && { note: notes.join(', ') }),\n ...(optional && { optional }),\n }\n}\n\nfunction exec(\n pattern: RegExp,\n text: string,\n start: number,\n): RegExpExecArray | null {\n pattern.lastIndex = start\n return pattern.exec(text)\n}\n\n/**\n * Reads the amount at `start` with `matchAmount`, ending where the text after\n * it starts. In a line, \"1-1/2\" is 1 ½, where `parseQuantity` reads a reversed\n * range. Unless `whole` is set, `matchAmount` sees only the amount and the\n * character after it, so a run of measures is read in linear time; a problem's\n * message then quotes only that.\n */\nfunction readAmount(\n text: string,\n start: number,\n whole?: boolean,\n): Read<Amount> | Problem | undefined {\n exec(AMOUNT_CHARS, text, start)\n const rest = text\n .slice(start, whole ? undefined : AMOUNT_CHARS.lastIndex + 1)\n .replace(\n HYPHENATED_MIXED,\n (dash, whole: string, numerator: string, denominator: string) =>\n Number(numerator) < Number(denominator) ? `${whole} ` : dash,\n )\n // matchAmount refuses text with a \"1,5\" anywhere in it, but in a line only\n // one in the amount counts.\n const comma = rest.search(/\\d,\\d/)\n const head = comma < 0 ? rest : rest.slice(0, comma).trimEnd()\n const match = matchAmount(head)\n\n if (comma >= 0 && (match ? !('code' in match) && !match.rest : !head)) {\n return matchAmount(rest) as Problem\n }\n if (!match || 'code' in match) {\n return match\n }\n\n return [match.amount, start + head.length - match.rest.length]\n}\n\nfunction isProblem(value: unknown): value is Problem {\n return typeof value === 'object' && value !== null && 'code' in value\n}\n\nfunction readUnit(text: string, start: number): Read<UnitId> | undefined {\n const { twoWordUnit, unit } = getPatterns()\n\n for (const pattern of [twoWordUnit, unit]) {\n const match = exec(pattern, text, start)\n const id =\n match && findUnit(match[2] ? `${match[1]} ${match[2]}` : match[1]!)\n if (id) {\n return [id, pattern.lastIndex]\n }\n }\n\n return undefined\n}\n\n/** A numeric amount and a unit, such as \"190 g\", \"14-ounce\", or \"1 lb 2 oz\". */\nfunction readMeasure(\n text: string,\n start: number,\n compound: boolean,\n): Read<Quantity> | undefined {\n const amount = readAmount(text, text[start] === ' ' ? start + 1 : start)\n\n if (!amount || isProblem(amount)) {\n return undefined\n }\n\n const unit = readUnit(text, amount[1])\n\n if (!unit) {\n return undefined\n }\n\n const measure: Read<Quantity> = [\n { amount: amount[0], unit: unit[0] },\n unit[1],\n ]\n const extended = compound ? extend(text, measure) : measure\n return isProblem(extended) ? undefined : extended\n}\n\n/**\n * Adds a second measure in the same dimension, as in \"1 cup plus 2 tbsp\" or\n * \"1 lb 2 oz\". After \"plus\", \"and\", or \"+\", the second measure is required.\n */\nfunction extend(\n text: string,\n measure: Read<Quantity>,\n): Read<Quantity> | Problem {\n const [{ amount, unit }, end] = measure\n const connector = exec(JOIN, text, end)![1]\n const extra = readMeasure(text, JOIN.lastIndex, false)\n const from = units[unit!]\n const to = extra && units[extra[0].unit!]\n\n if (!from.toBase || !to?.toBase || from.dimension !== to.dimension) {\n return connector\n ? noMeasure(\n `${quote(connector)} in the same kind of unit`,\n '1 cup plus 2 tbsp',\n )\n : measure\n }\n\n const ratio = to.toBase / from.toBase\n const total = combine(amount, extra![0].amount, (a, b) => a + b * ratio)\n return isProblem(total) ? total : [{ amount: total, unit }, extra![1]]\n}\n\nfunction noMeasure(after: string, example: string): Problem {\n return {\n code: 'invalid_quantity',\n message: `Expected a measure after ${after}, e.g. \"${example}\".`,\n }\n}\n\n/** Measures separated by \";\" or \"/\", as in \"12 Tbsp; 170g\", filling `text`. */\nfunction readMeasures(text: string): Quantity[] | undefined {\n const measures: Quantity[] = []\n let i = 0\n\n for (;;) {\n const measure = readMeasure(text, i, true)\n if (!measure) {\n return undefined\n }\n measures.push(measure[0])\n i = measure[1]\n\n if (i === text.length) {\n return measures\n }\n if (!exec(SEPARATOR, text, i)) {\n return undefined\n }\n i = SEPARATOR.lastIndex\n }\n}\n\n/** Multiplies or adds two amounts. A total that overflows or underflows is a problem. */\nfunction combine(\n a: Amount,\n b: Amount,\n op: (a: number, b: number) => number,\n): Amount | Problem {\n const [aMin, aMax] = typeof a === 'number' ? [a, a] : [a.min, a.max]\n const [bMin, bMax] = typeof b === 'number' ? [b, b] : [b.min, b.max]\n const min = op(aMin, bMin)\n const max = op(aMax, bMax)\n\n if (!isPositive(min) || !isPositive(max)) {\n return {\n code: 'invalid_amount',\n message:\n 'The total amount is out of range. Write it as one amount, e.g. \"800 g\".',\n }\n }\n\n return min < max ? { min, max } : min\n}\n\n/** The first comma outside parentheses that ends a name, skipping \"boneless, skinless\". */\nfunction topLevelComma(text: string): number {\n let depth = 0\n\n for (let j = 0; j < text.length; j++) {\n const char = text[j]\n if (char === '(') {\n depth++\n } else if (char === ')') {\n depth = Math.max(0, depth - 1)\n } else if (\n char === ',' &&\n depth === 0 &&\n !BONELESS.test(text.slice(0, j).slice(-9))\n ) {\n return j\n }\n }\n\n return text.length\n}\n\n/**\n * Returns the singular name and its plural. The counted word is the one before\n * \"of\", a leading container such as \"stalks\", or else the last word.\n */\nfunction countForms(\n name: string,\n many: boolean,\n): [name: string, plural: string | undefined] {\n const words = name.split(' ')\n let k = words.indexOf('of') - 1\n\n if (k < 0) {\n k = CONTAINER.test(words[0]!) ? 0 : words.length - 1\n }\n\n const word = words[k]!\n const other = many ? singular(word) : pluralize(word)\n\n if (other === word) {\n return [name, undefined]\n }\n\n words[k] = other\n const swapped = words.join(' ')\n return many ? [swapped, name] : [name, swapped]\n}\n\nfunction singular(word: string): string {\n return word.replace(\n /(?:(lea|hal|loa)ves|([rtdv])ies|(o|ch|sh|x|ss|chill?i)es|([^su])s)$/i,\n (_, f?: string, y?: string, es?: string, s?: string) =>\n f ? `${f}f` : y ? `${y}y` : (es ?? s)!,\n )\n}\n\nfunction pluralize(word: string): string {\n return word.replace(\n /(?:(lea|hal|loa)f|([^aeiou])y|(ch|sh|x|z|tomato|potato|mango|chill?i)|[a-rt-z])$/i,\n (match, f?: string, y?: string, es?: string) =>\n f ? `${f}ves` : y ? `${y}ies` : es ? `${es}es` : `${match}s`,\n )\n}\n"],"mappings":";AAAA;AAAA,EACE;AAAA,EAEA;AAAA,OAEK;AACP,SAAS,eAAe;AACxB,SAAS,kBAAkB;AAC3B,SAAS,mBAAiC;AAE1C,SAAS,gBAAgB;AACzB,SAAS,aAA0B;AACnC,SAAS,OAAO,gBAAgB;AAEhC,IAAM,eACJ;AAGF,IAAM,OAAO;AAiBb,IAAM,WAAW;AACjB,IAAM,QAAQ;AACd,IAAM,SACJ;AACF,IAAM,mBAAmB;AAEzB,IAAM,eAAe;AACrB,IAAM,UAAU;AAChB,IAAM,QAAQ;AAGd,IAAM,SAAS;AAGf,IAAM,oBAAoB;AAC1B,IAAM,QAAQ;AACd,IAAM,SAAS;AACf,IAAM,WACJ;AACF,IAAM,OAAO;AACb,IAAM,YAAY;AAClB,IAAM,KAAK;AACX,IAAM,cACJ;AACF,IAAM,gBAAgB;AACtB,IAAM,iBAAiB;AACvB,IAAM,YACJ;AACF,IAAM,WAAW;AAEjB,IAAI;AAIJ,SAAS,cAAc;AACrB,SAAQ,wBAAa,gBAAgB;AACvC;AAEA,SAAS,kBAAkB;AACzB,QAAM,OAAO,OAAO;AACpB,QAAM,MAAM,OAAO;AACnB,SAAO;AAAA,IACL,OAAO,IAAI,OAAO,QAAQ,YAAY,aAAa,IAAI;AAAA,IACvD,MAAM,IAAI,OAAO,GAAG,IAAI,IAAI,IAAI,GAAG,GAAG,IAAI,IAAI;AAAA,IAC9C,aAAa,IAAI,OAAO,GAAG,IAAI,IAAI,IAAI,IAAI,IAAI,GAAG,GAAG,IAAI,IAAI;AAAA,EAC/D;AACF;AAwDO,SAAS,oBAAoB,MAA2C;AAC7E,QAAM,SAAwB,CAAC;AAE/B,MAAI,OAAO,SAAS,UAAU;AAC5B;AAAA,MACE;AAAA,MACA;AAAA,MACA,CAAC;AAAA,MACD,6DAA6D,cAAc,IAAI,CAAC;AAAA,IAClF;AACA,WAAO,SAAyB,sBAAsB,QAAW,MAAM;AAAA,EACzE;AAIA,QAAM,OAAO,KAAK,QAAQ,gBAAgB,GAAG,EAAE,KAAK;AAEpD,MAAI,SAAS,IAAI;AACf;AAAA,MACE;AAAA,MACA;AAAA,MACA,CAAC;AAAA,MACD;AAAA,IACF;AACA,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM,IAAI;AAAA,IACZ;AAAA,EACF;AAEA,MAAI,aAAa,SAAS,IAAI;AAE9B,MAAI,UAAU,UAAU,GAAG;AACzB;AAAA,MACE;AAAA,MACA,WAAW;AAAA,MACX,CAAC;AAAA,MACD,GAAG,WAAW,OAAO;AAAA,IACvB;AACA,iBAAa,EAAE,MAAM,KAAK;AAAA,EAC5B;AAEA,SAAO,SAAS,sBAAsB,YAAY,MAAM;AAC1D;AAEA,SAAS,SAAS,MAAwC;AACxD,QAAM,IAAI,YAAY;AACtB,QAAM,QAAkB,CAAC;AACzB,QAAM,cAA0B,CAAC;AACjC,MAAI,WAAW;AACf,MAAI;AACJ,MAAI;AACJ,MAAI,WAAW;AACf,MAAI,IAAI;AACR,MAAI;AAEJ,QAAM,MAAM,CAAC,YAA4C;AACvD,UAAM,QAAQ,KAAK,SAAS,MAAM,CAAC;AACnC,QAAI,OAAO;AACT,UAAI,QAAQ;AAAA,IACd;AACA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,CAAC,SAAuB;AACtC,UAAM,OAAO,qBAAqB,KAAK,IAAI;AAC3C,QAAI,MAAM;AACR,YAAM,KAAK,KAAK,CAAC,CAAC;AAAA,IACpB;AAAA,EACF;AAEA,QAAM,YAAY,CAAC,SAAiB,SAA0B;AAC5D,UAAM,OAAO,QAAQ,QAAQ,gBAAgB,MAAM;AACjD,iBAAW;AACX,aAAO;AAAA,IACT,CAAC;AACD,UAAM,WAAW,OAAO,aAAa,IAAI,IAAI;AAE7C,QAAI,UAAU;AACZ,8BAAa,SAAS,MAAM;AAG5B,iBAAW,WAAW,UAAU;AAC9B,oBAAY,KAAK,OAAO;AAAA,MAC1B;AACA,aAAO;AAAA,IACT;AAEA,QAAI,QAAQ,MAAM;AAChB,aAAO,KAAK,IAAI;AAAA,IAClB;AAEA,YAAQ,IAAI;AACZ,WAAO;AAAA,EACT;AAEA,MAAI,IAAI,QAAQ,GAAG;AACjB,eAAW;AAAA,EACb;AAEA,UAAQ,KAAK,OAAO,MAAM,CAAC;AAC3B,MAAI,SAAS,WAAW,MAAM,MAAM,SAAS,GAAG;AAC9C,YAAQ,MAAM,CAAC,CAAE;AACjB,QAAI,MAAM;AAAA,EACZ;AAEA,MAAK,QAAQ,IAAI,MAAM,GAAI;AACzB,YAAQ,MAAM,CAAC,CAAE;AAAA,EACnB,WAAY,QAAQ,IAAI,EAAE,KAAK,GAAI;AACjC,aAAS,aAAa,MAAM,GAAG,EAAE,QAAQ,MAAM,CAAC,EAAG,YAAY,CAAC,IAAI,KAAK;AAAA,EAC3E,OAAO;AACL,UAAM,QAAQ,WAAW,MAAM,GAAG,IAAI;AAEtC,QAAI,UAAU,KAAK,GAAG;AACpB,aAAO;AAAA,IACT;AACA,QAAI,OAAO;AACT;AAAC,OAAC,QAAQ,CAAC,IAAI;AAAA,IACjB,WAAY,QAAQ,KAAK,SAAS,MAAM,CAAC,GAAI;AAC3C,YAAM,OAAO,SAAS,MAAM,CAAC,CAAE;AAC/B,UAAI,MAAM;AACR,mBAAW,EAAE,QAAQ,GAAG,KAAK;AAC7B,YAAI,QAAQ;AAAA,MACd;AAAA,IACF;AAAA,EACF;AAEA,MAAI,WAAW,QAAW;AACxB,QAAI;AACJ,QAAI,KAAK;AAET,QAAK,QAAQ,IAAI,KAAK,GAAI;AACxB,aAAO,aAAa,MAAM,CAAC,CAAE;AAC7B,UAAI,CAAC,MAAM;AACT,kBAAU,MAAM,CAAC,GAAI,KAAK;AAAA,MAC5B;AAAA,IACF,OAAO;AACL,YAAM,UAAU,YAAY,MAAM,GAAG,KAAK;AAC1C,UAAI,SAAS;AACX,eAAO,CAAC,QAAQ,CAAC,CAAC;AAClB,YAAI,QAAQ,CAAC;AAAA,MACf;AAAA,IACF;AAEA,UAAM,QAAQ;AACd,UAAM,WAAW,IAAI,QAAQ;AAC7B,UAAM,OAAO,SAAS,MAAM,CAAC;AAE7B,QAAI,MAAM;AACR,UAAI,UAAU;AACZ,gBAAQ,SAAS,CAAC,CAAE;AAAA,MACtB;AACA,iBAAW,KAAK,MAAM,GAAG,KAAK,CAAC,CAAC;AAChC,iBAAW,EAAE,QAAQ,MAAM,KAAK,CAAC,EAAE;AACnC,UAAI,KAAK,CAAC;AAEV,YAAM,OACJ,CAAC,QACD,MAAM,KAAK,CAAC,CAAC,EAAE,cAAc,WAC7B,YAAY,MAAM,GAAG,KAAK;AAE5B,UAAI,MAAM;AACR,oBAAY,KAAK,KAAK,CAAC,CAAC;AACxB,YAAI,KAAK,CAAC;AAAA,MACZ,WAAW,CAAC,MAAM;AAChB,cAAM,WAAW,OAAO,MAAM,CAAC,UAAU,CAAC,CAAC;AAC3C,YAAI,UAAU,QAAQ,GAAG;AACvB,iBAAO;AAAA,QACT;AACA;AAAC,SAAC,UAAU,CAAC,IAAI;AAAA,MACnB;AAEA,aAAQ,QAAQ,IAAI,SAAS,GAAI;AAC/B,cAAM,UAAU,YAAY,MAAM,GAAG,IAAI;AACzC,YAAI,CAAC,SAAS;AACZ,iBAAO,UAAU,MAAM,MAAM,CAAC,EAAE,KAAK,CAAC,GAAG,gBAAa;AAAA,QACxD;AACA,oBAAY,KAAK,QAAQ,CAAC,CAAC;AAC3B,YAAI,QAAQ,CAAC;AAAA,MACf;AAAA,IACF,OAAO;AACL,UAAI;AACJ,iBAAW,EAAE,OAAO;AAAA,IACtB;AAEA,QAAI,KAAK,OAAO,oBAAoB,QAAQ,MAAM,CAAC,GAAG;AACpD,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS,iCAAiC,MAAM,IAAI,CAAC;AAAA,MACvD;AAAA,IACF;AAEA,eAAW,QAAQ,QAAQ,CAAC,GAAG;AAC7B,YAAM,QAAQ,QAAQ,QAAQ,KAAK,QAAQ,CAAC,GAAG,MAAM,IAAI,CAAC;AAC1D,UAAI,UAAU,KAAK,GAAG;AACpB,eAAO;AAAA,MACT;AACA,kBAAY,KAAK,EAAE,GAAG,MAAM,QAAQ,MAAM,CAAC;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,KAAK,aAAa,UAAa,IAAI,EAAE;AAE3C,QAAM,OAAO,KAAK,MAAM,CAAC,EAAE,KAAK;AAChC,QAAM,QAAQ,cAAc,IAAI;AAChC,MAAI,OAAO,KACR,MAAM,GAAG,KAAK,EACd,QAAQ,QAAQ,CAAC,GAAG,YAAoB,UAAU,SAAS,KAAK,CAAC;AACpE,QAAM,SAAS,YAAY,KAAK,IAAI;AAEpC,MAAI,QAAQ;AACV,YAAQ,KAAK,MAAM,OAAO,KAAK,CAAC;AAChC,WAAO,KAAK,MAAM,GAAG,OAAO,KAAK;AAAA,EACnC;AAEA;AAAA,IACE,KACG,MAAM,QAAQ,CAAC,EACf,QAAQ,QAAQ,CAAC,GAAG,YAAoB,UAAU,SAAS,IAAI,CAAC,EAChE,QAAQ,eAAe,MAAM;AAC5B,iBAAW;AACX,aAAO;AAAA,IACT,CAAC;AAAA,EACL;AACA,SAAO,KAAK,KAAK;AAEjB,MAAI,CAAC,MAAM;AACT,QACE,MACA,CAAC,YACD,CAAC,UAAU,QACX,MAAM,SAAS,IAAI,EAAE,cAAc,SACnC;AACA,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS;AAAA,MACX;AAAA,IACF;AACA,WAAO,SAAS,QAAQ,WAAW,EAAE;AACrC,eAAW,EAAE,QAAQ,SAAS,OAAO;AAAA,EACvC;AAEA,MAAI;AACJ,MAAI,YAAY,CAAC,SAAS,MAAM;AAC9B,UAAM,OAAO,OAAO,SAAS,WAAW,YAAY,SAAS,SAAS;AACrE,KAAC,MAAM,MAAM,IAAI,WAAW,MAAM,IAAI;AAAA,EACzC;AAEA,SAAO;AAAA,IACL;AAAA,IACA,GAAI,WAAW,UAAa,EAAE,OAAO;AAAA,IACrC,GAAI,YAAY,EAAE,SAAS;AAAA,IAC3B,GAAI,YAAY,SAAS,KAAK,EAAE,YAAY;AAAA,IAC5C,GAAI,MAAM,SAAS,KAAK,EAAE,MAAM,MAAM,KAAK,IAAI,EAAE;AAAA,IACjD,GAAI,YAAY,EAAE,SAAS;AAAA,EAC7B;AACF;AAEA,SAAS,KACP,SACA,MACA,OACwB;AACxB,UAAQ,YAAY;AACpB,SAAO,QAAQ,KAAK,IAAI;AAC1B;AASA,SAAS,WACP,MACA,OACA,OACoC;AACpC,OAAK,cAAc,MAAM,KAAK;AAC9B,QAAM,OAAO,KACV,MAAM,OAAO,QAAQ,SAAY,aAAa,YAAY,CAAC,EAC3D;AAAA,IACC;AAAA,IACA,CAAC,MAAMA,QAAe,WAAmB,gBACvC,OAAO,SAAS,IAAI,OAAO,WAAW,IAAI,GAAGA,MAAK,MAAM;AAAA,EAC5D;AAGF,QAAM,QAAQ,KAAK,OAAO,OAAO;AACjC,QAAM,OAAO,QAAQ,IAAI,OAAO,KAAK,MAAM,GAAG,KAAK,EAAE,QAAQ;AAC7D,QAAM,QAAQ,YAAY,IAAI;AAE9B,MAAI,SAAS,MAAM,QAAQ,EAAE,UAAU,UAAU,CAAC,MAAM,OAAO,CAAC,OAAO;AACrE,WAAO,YAAY,IAAI;AAAA,EACzB;AACA,MAAI,CAAC,SAAS,UAAU,OAAO;AAC7B,WAAO;AAAA,EACT;AAEA,SAAO,CAAC,MAAM,QAAQ,QAAQ,KAAK,SAAS,MAAM,KAAK,MAAM;AAC/D;AAEA,SAAS,UAAU,OAAkC;AACnD,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,UAAU;AAClE;AAEA,SAAS,SAAS,MAAc,OAAyC;AACvE,QAAM,EAAE,aAAa,KAAK,IAAI,YAAY;AAE1C,aAAW,WAAW,CAAC,aAAa,IAAI,GAAG;AACzC,UAAM,QAAQ,KAAK,SAAS,MAAM,KAAK;AACvC,UAAM,KACJ,SAAS,SAAS,MAAM,CAAC,IAAI,GAAG,MAAM,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,KAAK,MAAM,CAAC,CAAE;AACpE,QAAI,IAAI;AACN,aAAO,CAAC,IAAI,QAAQ,SAAS;AAAA,IAC/B;AAAA,EACF;AAEA,SAAO;AACT;AAGA,SAAS,YACP,MACA,OACA,UAC4B;AAC5B,QAAM,SAAS,WAAW,MAAM,KAAK,KAAK,MAAM,MAAM,QAAQ,IAAI,KAAK;AAEvE,MAAI,CAAC,UAAU,UAAU,MAAM,GAAG;AAChC,WAAO;AAAA,EACT;AAEA,QAAM,OAAO,SAAS,MAAM,OAAO,CAAC,CAAC;AAErC,MAAI,CAAC,MAAM;AACT,WAAO;AAAA,EACT;AAEA,QAAM,UAA0B;AAAA,IAC9B,EAAE,QAAQ,OAAO,CAAC,GAAG,MAAM,KAAK,CAAC,EAAE;AAAA,IACnC,KAAK,CAAC;AAAA,EACR;AACA,QAAM,WAAW,WAAW,OAAO,MAAM,OAAO,IAAI;AACpD,SAAO,UAAU,QAAQ,IAAI,SAAY;AAC3C;AAMA,SAAS,OACP,MACA,SAC0B;AAC1B,QAAM,CAAC,EAAE,QAAQ,KAAK,GAAG,GAAG,IAAI;AAChC,QAAM,YAAY,KAAK,MAAM,MAAM,GAAG,EAAG,CAAC;AAC1C,QAAM,QAAQ,YAAY,MAAM,KAAK,WAAW,KAAK;AACrD,QAAM,OAAO,MAAM,IAAK;AACxB,QAAM,KAAK,SAAS,MAAM,MAAM,CAAC,EAAE,IAAK;AAExC,MAAI,CAAC,KAAK,UAAU,CAAC,IAAI,UAAU,KAAK,cAAc,GAAG,WAAW;AAClE,WAAO,YACH;AAAA,MACE,GAAG,MAAM,SAAS,CAAC;AAAA,MACnB;AAAA,IACF,IACA;AAAA,EACN;AAEA,QAAM,QAAQ,GAAG,SAAS,KAAK;AAC/B,QAAM,QAAQ,QAAQ,QAAQ,MAAO,CAAC,EAAE,QAAQ,CAAC,GAAG,MAAM,IAAI,IAAI,KAAK;AACvE,SAAO,UAAU,KAAK,IAAI,QAAQ,CAAC,EAAE,QAAQ,OAAO,KAAK,GAAG,MAAO,CAAC,CAAC;AACvE;AAEA,SAAS,UAAU,OAAe,SAA0B;AAC1D,SAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS,4BAA4B,KAAK,WAAW,OAAO;AAAA,EAC9D;AACF;AAGA,SAAS,aAAa,MAAsC;AAC1D,QAAM,WAAuB,CAAC;AAC9B,MAAI,IAAI;AAER,aAAS;AACP,UAAM,UAAU,YAAY,MAAM,GAAG,IAAI;AACzC,QAAI,CAAC,SAAS;AACZ,aAAO;AAAA,IACT;AACA,aAAS,KAAK,QAAQ,CAAC,CAAC;AACxB,QAAI,QAAQ,CAAC;AAEb,QAAI,MAAM,KAAK,QAAQ;AACrB,aAAO;AAAA,IACT;AACA,QAAI,CAAC,KAAK,WAAW,MAAM,CAAC,GAAG;AAC7B,aAAO;AAAA,IACT;AACA,QAAI,UAAU;AAAA,EAChB;AACF;AAGA,SAAS,QACP,GACA,GACA,IACkB;AAClB,QAAM,CAAC,MAAM,IAAI,IAAI,OAAO,MAAM,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,GAAG;AACnE,QAAM,CAAC,MAAM,IAAI,IAAI,OAAO,MAAM,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,GAAG;AACnE,QAAM,MAAM,GAAG,MAAM,IAAI;AACzB,QAAM,MAAM,GAAG,MAAM,IAAI;AAEzB,MAAI,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,GAAG,GAAG;AACxC,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SACE;AAAA,IACJ;AAAA,EACF;AAEA,SAAO,MAAM,MAAM,EAAE,KAAK,IAAI,IAAI;AACpC;AAGA,SAAS,cAAc,MAAsB;AAC3C,MAAI,QAAQ;AAEZ,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,UAAM,OAAO,KAAK,CAAC;AACnB,QAAI,SAAS,KAAK;AAChB;AAAA,IACF,WAAW,SAAS,KAAK;AACvB,cAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC;AAAA,IAC/B,WACE,SAAS,OACT,UAAU,KACV,CAAC,SAAS,KAAK,KAAK,MAAM,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC,GACzC;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO,KAAK;AACd;AAMA,SAAS,WACP,MACA,MAC4C;AAC5C,QAAM,QAAQ,KAAK,MAAM,GAAG;AAC5B,MAAI,IAAI,MAAM,QAAQ,IAAI,IAAI;AAE9B,MAAI,IAAI,GAAG;AACT,QAAI,UAAU,KAAK,MAAM,CAAC,CAAE,IAAI,IAAI,MAAM,SAAS;AAAA,EACrD;AAEA,QAAM,OAAO,MAAM,CAAC;AACpB,QAAM,QAAQ,OAAO,SAAS,IAAI,IAAI,UAAU,IAAI;AAEpD,MAAI,UAAU,MAAM;AAClB,WAAO,CAAC,MAAM,MAAS;AAAA,EACzB;AAEA,QAAM,CAAC,IAAI;AACX,QAAM,UAAU,MAAM,KAAK,GAAG;AAC9B,SAAO,OAAO,CAAC,SAAS,IAAI,IAAI,CAAC,MAAM,OAAO;AAChD;AAEA,SAAS,SAAS,MAAsB;AACtC,SAAO,KAAK;AAAA,IACV;AAAA,IACA,CAAC,GAAG,GAAY,GAAY,IAAa,MACvC,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,MAAO,MAAM;AAAA,EACvC;AACF;AAEA,SAAS,UAAU,MAAsB;AACvC,SAAO,KAAK;AAAA,IACV;AAAA,IACA,CAAC,OAAO,GAAY,GAAY,OAC9B,IAAI,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,EAAE,OAAO,GAAG,KAAK;AAAA,EAC7D;AACF;","names":["whole"]}
1
+ {"version":3,"sources":["../../src/ingredient/parse.ts"],"sourcesContent":["import {\n describeValue,\n type ParseResult,\n quote,\n type RecipeIssue,\n} from '../errors'\nimport { warning } from '../issues'\nimport { isPositive } from '../quantity/amount'\nimport { matchAmount, type Problem } from '../quantity/parse'\nimport type { Amount, Quantity } from '../quantity/types'\nimport { findUnit } from '../units/lookup'\nimport { units, type UnitId } from '../units/registry'\nimport { error, toResult } from '../validation'\n\nconst NUMBER_WORDS =\n 'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve'\n\n/** A space or dash between an amount and its unit, as in \"15-ounce\". */\nconst DASH = '[ –—-]'\n\n/**\n * An ingredient read from a line of text. It has no `id`, so pass it through\n * `parseRecipe` before resolving it.\n */\nexport type IngredientLine = {\n name: string\n plural?: string | undefined\n quantity?: Quantity | undefined\n equivalents?: Quantity[] | undefined\n note?: string | undefined\n optional?: boolean | undefined\n}\n\ntype Read<T> = [value: T, end: number]\n\nconst OPTIONAL = /(?:\\(optional\\)|optional\\b ?:?) ?/iy\nconst ABOUT = /(about|approx(?:imately|\\.)?) /iy\nconst PHRASE =\n /((?:an? )?(?:handful|few|couple|little|splash|drizzle|knob|squeeze|sprinkle|dollop))(?: of)? /iy\nconst HYPHENATED_MIXED = /^(\\d+)-(?=(\\d+)[/⁄](\\d+)(?![\\d/⁄]))/\n// Text an amount can be made of, so a measure reads only up to its unit.\nconst AMOUNT_CHARS = /(?:an? )?(?:[\\d .,/⁄¼-¾⅐-⅞–—-]|to )*/iy\nconst TIMES = / ?[x×] ?(?=\\d)/iy\n// Quantity text left after a bare count, as in \"2 or 3\" or \"1 000 g\".\n// A number joined to a word, as in \"2 9-inch pie crusts\", is a name.\nconst UNREAD =\n / ?(?:(?:or|to|and|plus|half)(?![\\p{L}\\p{M}\\d_])|[-–—/+&]|\\d+(?:\\.\\d+)?(?= |$))/iuy\n// A range or alternative after the unit, as in \"1 cup to 1 ¼ cups\",\n// \"1 lb or 450 g\", or \"100g-150g\".\nconst UNREAD_AFTER_UNIT = / (?:or|to|[-–—&])(?= )|[-–—+&](?=\\d)/iy\nconst PAREN = / ?\\(([^()]*)\\)/y\nconst PARENS = / ?\\(([^()]*)\\)/g\nconst MODIFIER =\n / ?(heaping|heaped|level|scant|rounded|generous|good|small|medium|large|big)(?= )/iy\nconst JOIN = /(?: (plus|and|\\+)(?![\\p{L}\\p{M}]))? ?/iuy\nconst SEPARATOR = / ?[;/] ?/y\nconst OF = / ?of(?: (?:an?|the))?(?= |$)/iy\n// A name and a colon, as in \"water: 320 ml\", that isn't a heading such as\n// \"For the sauce:\" or \"Yield:\", or just a servings or nutrition label such as\n// \"Serves:\" or \"Protein:\".\nconst NAMED =\n /(?!(?:for|to|an?|yields?|total)(?![\\p{L}\\p{M}\\d_])|(?:makes|serv(?:es|ings?)(?: size)?|protein|carbs|carbohydrates?|fiber|fibre|sodium|cholesterol|saturated fat|fat) ?:)([^\\d:]+):/iuy\nconst PHRASE_NOTE =\n / (?=to taste|as needed|as required|plus more|plus extra|or more|or to taste|for (?:serving|garnish|dusting|greasing|frying|drizzling|decoration))/i\nconst OPTIONAL_NOTE = /(?:^|,) ?optional(?= ?(?:,|$))/gi\nconst OPTIONAL_PAREN = /^optional\\b[,;:]? ?/i\nconst CONTAINER =\n /^(?:stalk|rib|ear|sheet|loa(?:f|ves)|knob|strip|bag|box|jar|bottle|packet|envelope|tin|block|ball|handful)(?:e?s)?$/i\nconst BONELESS = /(?:^|[^a-z])(?:bone|skin)less$/i\n\nlet patterns: ReturnType<typeof compilePatterns> | undefined\n\n// A bundler drops an unused regex literal but may keep a `new RegExp` call, so\n// the patterns built from strings compile on first use.\nfunction getPatterns() {\n return (patterns ??= compilePatterns())\n}\n\nfunction compilePatterns() {\n const word = String.raw`([a-z]+(?:-[a-z]+)?)\\.?`\n const end = String.raw`(?=[ ,;()/]|[-–—+&]\\d|$)`\n return {\n count: new RegExp(`(an?|${NUMBER_WORDS}) (?=\\\\S)`, 'iy'),\n unit: new RegExp(`${DASH}?${word}${end}`, 'iy'),\n twoWordUnit: new RegExp(`${DASH}?${word} ${word}${end}`, 'iy'),\n }\n}\n\n/**\n * Parses an ingredient line, such as `'1 ½ cups (190 g) flour, sifted'`, into\n * ingredient input. Never throws. Pass the result through `parseRecipe` before\n * resolving, or pass this function to `parseRecipe` as\n * `{ parseIngredientLine }` to put lines in recipe input.\n *\n * Reads a leading amount (`2`, `1–2`, `a`, `one`) and unit, and a second\n * measure in parentheses that hold nothing but measures, or after a slash or\n * semicolon, as `equivalents`. With no amount, a unit before \"of\" reads as 1\n * when it starts the line, after any leading \"optional\" and any space or\n * colon, or \"(optional)\" and any space, and after any size word such as\n * \"big\", so `'tsp. of salt'` gets `quantity: { amount: 1, unit: 'tsp' }` and\n * `'big pinch of salt'` gets `quantity: { amount: 1, unit: 'pinch' }` and\n * `note: 'big'`, but `'Optional - pinch of salt'` gets none. With no\n * leading amount or unit, the first measure in parentheses that hold nothing\n * but measures is the `quantity` instead, so `'Butter (100g)'` gets\n * `quantity: { amount: 100, unit: 'g' }`. Measures after a leading \"optional\"\n * in parentheses are read the same way, except straight after the amount,\n * where the first is dropped (`'2 (optional, 15 oz) cans beans'`). A measure\n * in the same dimension, mass or volume, after \"plus\", \"and\" or \"+\", or\n * straight after the first, is added to it (`'1 lb 2 oz flour'` is 1.125 lb).\n * In parentheses, any other sum (across dimensions, in count units such as\n * \"pinch\", or of three measures) makes every measure in them part of the note\n * (`'1 cup (8 oz + 1 tbsp) milk'`).\n * With no leading amount or unit, an amount and unit after the name and a\n * colon are read when nothing else follows but a note after a comma, so\n * `'nagaimo: 160 g, grated'` gets `name: 'nagaimo'`,\n * `quantity: { amount: 160, unit: 'g' }` and `note: 'grated'`. A name with a\n * digit, or that starts with the word \"for\", \"to\", \"a\", \"an\", \"yield\",\n * \"yields\" or \"total\", isn't read that way, and neither is one that is just\n * \"makes\", \"serves\", \"serving\", \"servings\", \"serving size\", \"protein\",\n * \"carbs\", \"carbohydrate\", \"carbohydrates\", \"fiber\", \"fibre\", \"sodium\",\n * \"cholesterol\", \"saturated fat\" or \"fat\". So `'For the sauce: 1 cup'` and\n * `'Protein: 12 g'` keep the whole line as the name, but\n * `'Protein powder: 30 g'` gets `name: 'Protein powder'`.\n * It also reads a note after the first comma outside parentheses that doesn't\n * follow \"boneless\" or \"skinless\", phrases such as \"to taste\", and \"optional\"\n * at the start of the line or of text in parentheses (the innermost when they\n * nest), or as its own comma-separated item after the first comma. After a\n * leading \"optional\" and any space or colon, or \"(optional)\" and any space,\n * the rest is read as if it were the whole line, so\n * `'Optional - 2 cups flour'` gets `optional: true` and\n * `name: '- 2 cups flour'` with no quantity. With a comma there, not even\n * \"optional\" is read: `'optional, 2 cups flour'` becomes\n * `{ name: 'optional, 2 cups flour' }` with a warning. Text in parentheses\n * before the first comma that isn't a measure or a leading \"optional\"\n * (only the innermost when they nest) is a note too, ahead of the text after\n * the comma, so `'1 onion (diced), peeled'` gets `note: 'diced, peeled'`. When\n * parentheses before the first comma nest, the outer ones stay in the name with\n * their text, so `'2 onions (diced (fine)), peeled'` gets\n * `name: 'onions (diced)'` and `note: 'fine, peeled'`, and\n * `'115 g butter ((1/2 cup))'` gets `name: 'butter ()'`.\n * A bare count gets a `plural` from the word before \"of\", a leading container\n * such as \"jars\", or else the last word. There's none when that word gets no\n * other form, as when it ends in parentheses (`'2 onions (diced (fine))'`),\n * has no plural ending after a count above 1 or a range (`'12 shrimp'`), or\n * ends in \"s\" after a count of 1 or less (`'1 eggs'`, `'½ onions'`). A\n * line it can't read becomes `{ name: line }` with a warning; only blank text\n * or a value that isn't a string is an error.\n *\n * @example\n * parseIngredientLine('1 ½ cups (190 g) flour, sifted').data\n * // { name: 'flour', quantity: { amount: 1.5, unit: 'cup' }, equivalents: [{ amount: 190, unit: 'g' }], note: 'sifted' }\n * parseIngredientLine('2 large eggs').data // { name: 'large egg', plural: 'large eggs', quantity: { amount: 2 } }\n */\nexport function parseIngredientLine(text: string): ParseResult<IngredientLine> {\n const issues: RecipeIssue[] = []\n\n if (typeof text !== 'string') {\n error(\n issues,\n 'invalid_type',\n [],\n `Expected ingredient text such as \"2 cups flour\", received ${describeValue(text)}.`,\n )\n return toResult<IngredientLine>('invalid_ingredient', undefined, issues)\n }\n\n // U+200B is invisible but isn't whitespace. U+200C and U+200D are kept,\n // because they join letters in some scripts and in emoji.\n const line = text.replace(/[\\s\\u200b]+/g, ' ').trim()\n\n if (line === '') {\n error(\n issues,\n 'missing_field',\n [],\n 'Add the ingredient text, e.g. \"2 cups flour\".',\n )\n return toResult<IngredientLine>(\n 'invalid_ingredient',\n undefined,\n issues,\n quote(text),\n )\n }\n\n let ingredient = readLine(line)\n\n if (isProblem(ingredient)) {\n warning(\n issues,\n ingredient.code,\n [],\n `${ingredient.message} The whole line is kept as the name.`,\n )\n ingredient = { name: line }\n }\n\n return toResult('invalid_ingredient', ingredient, issues)\n}\n\nfunction readLine(line: string): IngredientLine | Problem {\n const p = getPatterns()\n const notes: string[] = []\n const equivalents: Quantity[] = []\n let optional = false\n let quantity: Quantity | undefined\n let amount: Amount | undefined\n let unitText = ''\n let i = 0\n let match: RegExpExecArray | null\n\n const eat = (pattern: RegExp): RegExpExecArray | null => {\n const found = exec(pattern, line, i)\n if (found) {\n i = pattern.lastIndex\n }\n return found\n }\n\n const addNote = (text: string): void => {\n const note = /[^ ,](?:.*[^ ,])?/s.exec(text)\n if (note) {\n notes.push(note[0])\n }\n }\n\n const readParen = (content: string, keep: boolean): string => {\n const text = content.replace(OPTIONAL_PAREN, () => {\n optional = true\n return ''\n })\n const measures = text ? readMeasures(text) : undefined\n\n if (measures) {\n quantity ??= measures.shift()\n // A spread passes each measure as an argument, which overflows the stack\n // for a long list.\n for (const measure of measures) {\n equivalents.push(measure)\n }\n return ''\n }\n\n if (keep && text) {\n return ` (${text})`\n }\n\n addNote(text)\n return ''\n }\n\n if (eat(OPTIONAL)) {\n optional = true\n }\n\n match = exec(ABOUT, line, i)\n if (match && readAmount(line, ABOUT.lastIndex)) {\n addNote(match[1]!)\n i = ABOUT.lastIndex\n }\n\n if ((match = eat(PHRASE))) {\n addNote(match[1]!)\n } else if ((match = eat(p.count))) {\n amount = NUMBER_WORDS.split('|').indexOf(match[1]!.toLowerCase()) + 1 || 1\n } else {\n const found = readAmount(line, i, true)\n\n if (isProblem(found)) {\n return found\n }\n if (found) {\n ;[amount, i] = found\n } else {\n const modifier = exec(MODIFIER, line, i)\n const unit = readUnit(line, modifier ? MODIFIER.lastIndex : i)\n if (unit && exec(OF, line, unit[1])) {\n if (modifier) {\n addNote(modifier[1]!)\n }\n quantity = { amount: 1, unit: unit[0] }\n i = unit[1]\n } else if ((match = exec(NAMED, line, i))) {\n const measure = readMeasure(line, NAMED.lastIndex, false)\n if (measure && (line[measure[1]] ?? ',') === ',') {\n quantity = measure[0]\n line = match[1] + line.slice(measure[1])\n i = 0\n }\n }\n }\n }\n\n if (amount !== undefined) {\n let each: Quantity[] | undefined\n eat(TIMES)\n\n if ((match = eat(PAREN))) {\n each = readMeasures(match[1]!)\n if (!each) {\n readParen(match[1]!, false)\n }\n } else {\n const measure = readMeasure(line, i, false)\n if (measure) {\n each = [measure[0]]\n i = measure[1]\n }\n }\n\n const start = i\n const modifier = eat(MODIFIER)\n const unit = readUnit(line, i)\n\n if (unit) {\n if (modifier) {\n addNote(modifier[1]!)\n }\n unitText = line.slice(i, unit[1])\n quantity = { amount, unit: unit[0] }\n i = unit[1]\n\n const size =\n !each &&\n units[unit[0]].dimension === 'count' &&\n readMeasure(line, i, false)\n\n if (size) {\n equivalents.push(size[0])\n i = size[1]\n } else if (!each) {\n const extended = extend(line, [quantity, i])\n if (isProblem(extended)) {\n return extended\n }\n ;[quantity, i] = extended\n }\n\n while ((match = eat(SEPARATOR))) {\n const measure = readMeasure(line, i, true)\n if (!measure) {\n return noMeasure(quote(match[0].trim()), '100 g/3½ oz')\n }\n equivalents.push(measure[0])\n i = measure[1]\n }\n } else {\n i = start\n quantity = { amount }\n }\n\n if (exec(unit ? UNREAD_AFTER_UNIT : UNREAD, line, i)) {\n return {\n code: 'invalid_quantity',\n message: `Couldn't read the quantity in ${quote(line)}. Write one amount or range before the unit, e.g. \"2-3 eggs\" or \"2 ½ cups\".`,\n }\n }\n\n for (const item of each ?? []) {\n const total = combine(amount, item.amount, (a, b) => a * b)\n if (isProblem(total)) {\n return total\n }\n equivalents.push({ ...item, amount: total })\n }\n }\n\n const of = quantity !== undefined && eat(OF)\n\n const rest = line.slice(i).trim()\n const comma = topLevelComma(rest)\n let name = rest\n .slice(0, comma)\n .replace(PARENS, (_, content: string) => readParen(content, false))\n const phrase = PHRASE_NOTE.exec(name)\n\n if (phrase) {\n addNote(name.slice(phrase.index))\n name = name.slice(0, phrase.index)\n }\n\n addNote(\n rest\n .slice(comma + 1)\n .replace(PARENS, (_, content: string) => readParen(content, true))\n .replace(OPTIONAL_NOTE, () => {\n optional = true\n return ''\n }),\n )\n name = name.trim()\n\n if (!name) {\n if (\n of ||\n !unitText ||\n !quantity?.unit ||\n units[quantity.unit].dimension !== 'count'\n ) {\n return {\n code: 'missing_field',\n message: 'Add a name after the quantity, e.g. \"2 cups flour\".',\n }\n }\n name = unitText.replace(/^[ –—-]/, '')\n quantity = { amount: quantity.amount }\n }\n\n let plural: string | undefined\n if (quantity && !quantity.unit) {\n const many = typeof quantity.amount !== 'number' || quantity.amount > 1\n ;[name, plural] = countForms(name, many)\n }\n\n return {\n name,\n ...(plural !== undefined && { plural }),\n ...(quantity && { quantity }),\n ...(equivalents.length > 0 && { equivalents }),\n ...(notes.length > 0 && { note: notes.join(', ') }),\n ...(optional && { optional }),\n }\n}\n\nfunction exec(\n pattern: RegExp,\n text: string,\n start: number,\n): RegExpExecArray | null {\n pattern.lastIndex = start\n return pattern.exec(text)\n}\n\n/**\n * Reads the amount at `start` with `matchAmount`, ending where the text after\n * it starts. In a line, \"1-1/2\" is 1 ½, where `parseQuantity` reads a reversed\n * range. Unless `whole` is set, `matchAmount` sees only the amount and the\n * character after it, so a run of measures is read in linear time; a problem's\n * message then quotes only that.\n */\nfunction readAmount(\n text: string,\n start: number,\n whole?: boolean,\n): Read<Amount> | Problem | undefined {\n exec(AMOUNT_CHARS, text, start)\n const rest = text\n .slice(start, whole ? undefined : AMOUNT_CHARS.lastIndex + 1)\n .replace(\n HYPHENATED_MIXED,\n (dash, whole: string, numerator: string, denominator: string) =>\n Number(numerator) < Number(denominator) ? `${whole} ` : dash,\n )\n // matchAmount refuses text with a \"1,5\" anywhere in it, but in a line only\n // one in the amount counts.\n const comma = rest.search(/\\d,\\d/)\n const head = comma < 0 ? rest : rest.slice(0, comma).trimEnd()\n const match = matchAmount(head)\n\n if (comma >= 0 && (match ? !('code' in match) && !match.rest : !head)) {\n return matchAmount(rest) as Problem\n }\n if (!match || 'code' in match) {\n return match\n }\n\n return [match.amount, start + head.length - match.rest.length]\n}\n\nfunction isProblem(value: unknown): value is Problem {\n return typeof value === 'object' && value !== null && 'code' in value\n}\n\nfunction readUnit(text: string, start: number): Read<UnitId> | undefined {\n const { twoWordUnit, unit } = getPatterns()\n\n for (const pattern of [twoWordUnit, unit]) {\n const match = exec(pattern, text, start)\n const id = match && findUnit(match.slice(1).join(' '))\n if (id) {\n return [id, pattern.lastIndex]\n }\n }\n\n return undefined\n}\n\n/** A numeric amount and a unit, such as \"190 g\", \"14-ounce\", or \"1 lb 2 oz\". */\nfunction readMeasure(\n text: string,\n start: number,\n compound: boolean,\n): Read<Quantity> | undefined {\n const amount = readAmount(text, text[start] === ' ' ? start + 1 : start)\n\n if (!amount || isProblem(amount)) {\n return undefined\n }\n\n const unit = readUnit(text, amount[1])\n\n if (!unit) {\n return undefined\n }\n\n const measure: Read<Quantity> = [\n { amount: amount[0], unit: unit[0] },\n unit[1],\n ]\n const extended = compound ? extend(text, measure) : measure\n return isProblem(extended) ? undefined : extended\n}\n\n/**\n * Adds a second measure in the same dimension, as in \"1 cup plus 2 tbsp\" or\n * \"1 lb 2 oz\". After \"plus\", \"and\", or \"+\", the second measure is required.\n */\nfunction extend(\n text: string,\n measure: Read<Quantity>,\n): Read<Quantity> | Problem {\n const [{ amount, unit }, end] = measure\n const connector = exec(JOIN, text, end)![1]\n const extra = readMeasure(text, JOIN.lastIndex, false)\n const from = units[unit!]\n const to = extra && units[extra[0].unit!]\n\n if (!from.toBase || !to?.toBase || from.dimension !== to.dimension) {\n return connector\n ? noMeasure(\n `${quote(connector)} in the same kind of unit`,\n '1 cup plus 2 tbsp',\n )\n : measure\n }\n\n const ratio = to.toBase / from.toBase\n const total = combine(amount, extra![0].amount, (a, b) => a + b * ratio)\n return isProblem(total) ? total : [{ amount: total, unit }, extra![1]]\n}\n\nfunction noMeasure(after: string, example: string): Problem {\n return {\n code: 'invalid_quantity',\n message: `Expected a measure after ${after}, e.g. \"${example}\".`,\n }\n}\n\n/** Measures separated by \";\" or \"/\", as in \"12 Tbsp; 170g\", filling `text`. */\nfunction readMeasures(text: string): Quantity[] | undefined {\n const measures: Quantity[] = []\n let i = 0\n\n for (;;) {\n const measure = readMeasure(text, i, true)\n if (!measure) {\n return undefined\n }\n measures.push(measure[0])\n i = measure[1]\n\n if (i === text.length) {\n return measures\n }\n if (!exec(SEPARATOR, text, i)) {\n return undefined\n }\n i = SEPARATOR.lastIndex\n }\n}\n\n/** Multiplies or adds two amounts. A total that overflows or underflows is a problem. */\nfunction combine(\n a: Amount,\n b: Amount,\n op: (a: number, b: number) => number,\n): Amount | Problem {\n const [aMin, aMax] = typeof a === 'number' ? [a, a] : [a.min, a.max]\n const [bMin, bMax] = typeof b === 'number' ? [b, b] : [b.min, b.max]\n const min = op(aMin, bMin)\n const max = op(aMax, bMax)\n\n if (!isPositive(min) || !isPositive(max)) {\n return {\n code: 'invalid_amount',\n message:\n 'The total amount is out of range. Write it as one amount, e.g. \"800 g\".',\n }\n }\n\n return min < max ? { min, max } : min\n}\n\n/** The first comma outside parentheses that ends a name, skipping \"boneless, skinless\". */\nfunction topLevelComma(text: string): number {\n let depth = 0\n\n for (let j = 0; j < text.length; j++) {\n const char = text[j]\n if (char === '(') {\n depth++\n } else if (char === ')') {\n depth = Math.max(0, depth - 1)\n } else if (\n char === ',' &&\n depth === 0 &&\n !BONELESS.test(text.slice(0, j).slice(-9))\n ) {\n return j\n }\n }\n\n return text.length\n}\n\n/**\n * Returns the singular name and its plural. The counted word is the one before\n * \"of\", a leading container such as \"stalks\", or else the last word.\n */\nfunction countForms(\n name: string,\n many: boolean,\n): [name: string, plural: string | undefined] {\n const words = name.split(' ')\n let k = words.indexOf('of') - 1\n\n if (k < 0) {\n k = CONTAINER.test(words[0]!) ? 0 : words.length - 1\n }\n\n const word = words[k]!\n const other = many\n ? word.replace(\n /(?:(lea|hal|loa)ves|([rtdv])ies|(o|ch|sh|x|ss|chill?i)es|([^su])s)$/i,\n (_, f?: string, y?: string, es?: string, s?: string) =>\n f ? `${f}f` : y ? `${y}y` : (es ?? s)!,\n )\n : word.replace(\n /(?:(lea|hal|loa)f|([^aeiou])y|(ch|sh|x|z|tomato|potato|mango|chill?i)|[a-rt-z])$/i,\n (match, f?: string, y?: string, es?: string) =>\n f ? `${f}ves` : y ? `${y}ies` : es ? `${es}es` : `${match}s`,\n )\n\n if (other === word) {\n return [name, undefined]\n }\n\n words[k] = other\n const swapped = words.join(' ')\n return many ? [swapped, name] : [name, swapped]\n}\n"],"mappings":"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","names":["whole"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/issues.ts"],"sourcesContent":["import type { RecipeIssue, RecipeIssueCode } from './errors'\n\nexport type Path = readonly (string | number)[]\n\nexport function warning(\n issues: RecipeIssue[],\n code: RecipeIssueCode,\n path: Path,\n message: string,\n suggestion?: string,\n): void {\n issues.push(issue('warning', code, path, message, suggestion))\n}\n\nexport function issue(\n severity: RecipeIssue['severity'],\n code: RecipeIssueCode,\n path: Path,\n message: string,\n suggestion?: string,\n): RecipeIssue {\n return {\n code,\n severity,\n path,\n message,\n ...(suggestion !== undefined && { suggestion }),\n }\n}\n\n/** A plain object: not `null`, not an array. */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** `Object.hasOwn`, which the es2020 build target can't assume. */\nexport function hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key)\n}\n"],"mappings":";AAIO,SAAS,QACd,QACA,MACA,MACA,SACA,YACM;AACN,SAAO,KAAK,MAAM,WAAW,MAAM,MAAM,SAAS,UAAU,CAAC;AAC/D;AAEO,SAAS,MACd,UACA,MACA,MACA,SACA,YACa;AACb,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAI,eAAe,UAAa,EAAE,WAAW;AAAA,EAC/C;AACF;AAGO,SAAS,SAAS,OAAkD;AACzE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAGO,SAAS,OAAO,QAAgB,KAAsB;AAC3D,SAAO,OAAO,UAAU,eAAe,KAAK,QAAQ,GAAG;AACzD;","names":[]}
1
+ {"version":3,"sources":["../src/issues.ts"],"sourcesContent":["import type { RecipeIssue, RecipeIssueCode } from './errors'\n\nexport type Path = readonly (string | number)[]\n\nexport function warning(\n issues: RecipeIssue[],\n code: RecipeIssueCode,\n path: Path,\n message: string,\n suggestion?: string,\n): void {\n issues.push(issue('warning', code, path, message, suggestion))\n}\n\nexport function issue(\n severity: RecipeIssue['severity'],\n code: RecipeIssueCode,\n path: Path,\n message: string,\n suggestion?: string,\n): RecipeIssue {\n return {\n code,\n severity,\n path,\n message,\n ...(suggestion !== undefined && { suggestion }),\n }\n}\n\n/** A plain object: not `null`, not an array. */\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** `Object.hasOwn`, which the es2020 build target can't assume. */\nexport function hasOwn(record: object, key: string | number): boolean {\n return Object.prototype.hasOwnProperty.call(record, key)\n}\n"],"mappings":";AAIO,SAAS,QACd,QACA,MACA,MACA,SACA,YACM;AACN,SAAO,KAAK,MAAM,WAAW,MAAM,MAAM,SAAS,UAAU,CAAC;AAC/D;AAEO,SAAS,MACd,UACA,MACA,MACA,SACA,YACa;AACb,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAI,eAAe,UAAa,EAAE,WAAW;AAAA,EAC/C;AACF;AAGO,SAAS,SAAS,OAAkD;AACzE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAGO,SAAS,OAAO,QAAgB,KAA+B;AACpE,SAAO,OAAO,UAAU,eAAe,KAAK,QAAQ,GAAG;AACzD;","names":[]}
@@ -44,6 +44,16 @@ function checkQuantity(fn, quantity) {
44
44
  `${fn}: amount must be a number or a { min, max } range, received ${describeValue(amount)}.`
45
45
  );
46
46
  }
47
+ function checkResult(fn, amount) {
48
+ for (const value of amountEnds(amount)) {
49
+ if (!isPositive(value)) {
50
+ throw invalidArgument(
51
+ `${fn}: the result must be a finite number greater than 0, but it is ${describeValue(value)}.`
52
+ );
53
+ }
54
+ }
55
+ return amount;
56
+ }
47
57
  function checkFactor(fn, name, value) {
48
58
  if (!isPositive(value)) {
49
59
  throw invalidArgument(
@@ -61,6 +71,7 @@ export {
61
71
  amountEnds,
62
72
  checkFactor,
63
73
  checkQuantity,
74
+ checkResult,
64
75
  isPositive,
65
76
  mapAmount
66
77
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/quantity/amount.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport type { Amount, Range } from './types'\n\n/** The amount type an operation returns for an input amount type. */\nexport type SameAmount<A extends Amount> = A extends number ? number : Range\n\nexport function mapAmount<A extends Amount>(\n amount: A,\n fn: (value: number) => number,\n): SameAmount<A> {\n return (\n typeof amount === 'number'\n ? fn(amount)\n : { min: fn(amount.min), max: fn(amount.max) }\n ) as SameAmount<A>\n}\n\nexport function amountEnds(amount: Amount): readonly number[] {\n return typeof amount === 'number' ? [amount] : [amount.min, amount.max]\n}\n\n/**\n * Validates a quantity passed to a public function.\n *\n * @throws {RecipeBookError} `invalid_argument`, naming `fn` and the fix.\n */\nexport function checkQuantity(fn: string, quantity: unknown): Amount {\n if (\n typeof quantity !== 'object' ||\n quantity === null ||\n !('amount' in quantity)\n ) {\n throw invalidArgument(\n `${fn}: expected a quantity such as { amount: 2, unit: \"cup\" }, received ${describeValue(quantity)}.`,\n )\n }\n\n if ((quantity as { max?: unknown }).max !== undefined) {\n throw invalidArgument(\n `${fn}: a quantity has no max; use amount: { min, max }, or formatServings or formatYield.`,\n )\n }\n\n const { amount } = quantity\n\n if (typeof amount === 'number') {\n if (!isPositive(amount)) {\n throw invalidArgument(\n `${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`,\n )\n }\n return amount\n }\n\n if (\n typeof amount === 'object' &&\n amount !== null &&\n 'min' in amount &&\n 'max' in amount\n ) {\n const { min, max } = amount\n if (!isPositive(min) || !isPositive(max)) {\n throw invalidArgument(\n `${fn}: range ends must be finite numbers greater than 0, received { min: ${describeValue(min)}, max: ${describeValue(max)} }.`,\n )\n }\n if (min >= max) {\n throw invalidArgument(\n `${fn}: range minimum (${min}) must be less than maximum (${max}). Use a plain number when both ends are equal.`,\n )\n }\n return { min, max }\n }\n\n throw invalidArgument(\n `${fn}: amount must be a number or a { min, max } range, received ${describeValue(amount)}.`,\n )\n}\n\n/**\n * Validates a multiplier passed to a public function.\n *\n * @throws {RecipeBookError} `invalid_argument`, naming `fn` and the parameter.\n */\nexport function checkFactor(fn: string, name: string, value: unknown): void {\n if (!isPositive(value)) {\n throw invalidArgument(\n `${fn}: ${name} must be a finite number greater than 0, received ${describeValue(value)}.`,\n )\n }\n}\n\nexport function isPositive(value: unknown): value is number {\n return typeof value === 'number' && Number.isFinite(value) && value > 0\n}\n\nfunction invalidArgument(message: string): RecipeBookError {\n return new RecipeBookError('invalid_argument', message)\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAMxC,SAAS,UACd,QACA,IACe;AACf,SACE,OAAO,WAAW,WACd,GAAG,MAAM,IACT,EAAE,KAAK,GAAG,OAAO,GAAG,GAAG,KAAK,GAAG,OAAO,GAAG,EAAE;AAEnD;AAEO,SAAS,WAAW,QAAmC;AAC5D,SAAO,OAAO,WAAW,WAAW,CAAC,MAAM,IAAI,CAAC,OAAO,KAAK,OAAO,GAAG;AACxE;AAOO,SAAS,cAAc,IAAY,UAA2B;AACnE,MACE,OAAO,aAAa,YACpB,aAAa,QACb,EAAE,YAAY,WACd;AACA,UAAM;AAAA,MACJ,GAAG,EAAE,sEAAsE,cAAc,QAAQ,CAAC;AAAA,IACpG;AAAA,EACF;AAEA,MAAK,SAA+B,QAAQ,QAAW;AACrD,UAAM;AAAA,MACJ,GAAG,EAAE;AAAA,IACP;AAAA,EACF;AAEA,QAAM,EAAE,OAAO,IAAI;AAEnB,MAAI,OAAO,WAAW,UAAU;AAC9B,QAAI,CAAC,WAAW,MAAM,GAAG;AACvB,YAAM;AAAA,QACJ,GAAG,EAAE,6DAA6D,cAAc,MAAM,CAAC;AAAA,MACzF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,MACE,OAAO,WAAW,YAClB,WAAW,QACX,SAAS,UACT,SAAS,QACT;AACA,UAAM,EAAE,KAAK,IAAI,IAAI;AACrB,QAAI,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,GAAG,GAAG;AACxC,YAAM;AAAA,QACJ,GAAG,EAAE,uEAAuE,cAAc,GAAG,CAAC,UAAU,cAAc,GAAG,CAAC;AAAA,MAC5H;AAAA,IACF;AACA,QAAI,OAAO,KAAK;AACd,YAAM;AAAA,QACJ,GAAG,EAAE,oBAAoB,GAAG,gCAAgC,GAAG;AAAA,MACjE;AAAA,IACF;AACA,WAAO,EAAE,KAAK,IAAI;AAAA,EACpB;AAEA,QAAM;AAAA,IACJ,GAAG,EAAE,+DAA+D,cAAc,MAAM,CAAC;AAAA,EAC3F;AACF;AAOO,SAAS,YAAY,IAAY,MAAc,OAAsB;AAC1E,MAAI,CAAC,WAAW,KAAK,GAAG;AACtB,UAAM;AAAA,MACJ,GAAG,EAAE,KAAK,IAAI,qDAAqD,cAAc,KAAK,CAAC;AAAA,IACzF;AAAA,EACF;AACF;AAEO,SAAS,WAAW,OAAiC;AAC1D,SAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,QAAQ;AACxE;AAEA,SAAS,gBAAgB,SAAkC;AACzD,SAAO,IAAI,gBAAgB,oBAAoB,OAAO;AACxD;","names":[]}
1
+ {"version":3,"sources":["../../src/quantity/amount.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport type { Amount, Range } from './types'\n\n/** The amount type an operation returns for an input amount type. */\nexport type SameAmount<A extends Amount> = A extends number ? number : Range\n\nexport function mapAmount<A extends Amount>(\n amount: A,\n fn: (value: number) => number,\n): SameAmount<A> {\n return (\n typeof amount === 'number'\n ? fn(amount)\n : { min: fn(amount.min), max: fn(amount.max) }\n ) as SameAmount<A>\n}\n\nexport function amountEnds(amount: Amount): readonly number[] {\n return typeof amount === 'number' ? [amount] : [amount.min, amount.max]\n}\n\n/**\n * Validates a quantity passed to a public function.\n *\n * @throws {RecipeBookError} `invalid_argument`, naming `fn` and the fix.\n */\nexport function checkQuantity(fn: string, quantity: unknown): Amount {\n if (\n typeof quantity !== 'object' ||\n quantity === null ||\n !('amount' in quantity)\n ) {\n throw invalidArgument(\n `${fn}: expected a quantity such as { amount: 2, unit: \"cup\" }, received ${describeValue(quantity)}.`,\n )\n }\n\n if ((quantity as { max?: unknown }).max !== undefined) {\n throw invalidArgument(\n `${fn}: a quantity has no max; use amount: { min, max }, or formatServings or formatYield.`,\n )\n }\n\n const { amount } = quantity\n\n if (typeof amount === 'number') {\n if (!isPositive(amount)) {\n throw invalidArgument(\n `${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`,\n )\n }\n return amount\n }\n\n if (\n typeof amount === 'object' &&\n amount !== null &&\n 'min' in amount &&\n 'max' in amount\n ) {\n const { min, max } = amount\n if (!isPositive(min) || !isPositive(max)) {\n throw invalidArgument(\n `${fn}: range ends must be finite numbers greater than 0, received { min: ${describeValue(min)}, max: ${describeValue(max)} }.`,\n )\n }\n if (min >= max) {\n throw invalidArgument(\n `${fn}: range minimum (${min}) must be less than maximum (${max}). Use a plain number when both ends are equal.`,\n )\n }\n return { min, max }\n }\n\n throw invalidArgument(\n `${fn}: amount must be a number or a { min, max } range, received ${describeValue(amount)}.`,\n )\n}\n\n/**\n * Returns `amount`, a result of `fn`, unless an end overflowed to `Infinity`\n * or underflowed to 0.\n *\n * @throws {RecipeBookError} `invalid_argument`, naming `fn`.\n */\nexport function checkResult<A extends Amount>(fn: string, amount: A): A {\n for (const value of amountEnds(amount)) {\n if (!isPositive(value)) {\n throw invalidArgument(\n `${fn}: the result must be a finite number greater than 0, but it is ${describeValue(value)}.`,\n )\n }\n }\n return amount\n}\n\n/**\n * Validates a multiplier passed to a public function.\n *\n * @throws {RecipeBookError} `invalid_argument`, naming `fn` and the parameter.\n */\nexport function checkFactor(fn: string, name: string, value: unknown): void {\n if (!isPositive(value)) {\n throw invalidArgument(\n `${fn}: ${name} must be a finite number greater than 0, received ${describeValue(value)}.`,\n )\n }\n}\n\nexport function isPositive(value: unknown): value is number {\n return typeof value === 'number' && Number.isFinite(value) && value > 0\n}\n\nfunction invalidArgument(message: string): RecipeBookError {\n return new RecipeBookError('invalid_argument', message)\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAMxC,SAAS,UACd,QACA,IACe;AACf,SACE,OAAO,WAAW,WACd,GAAG,MAAM,IACT,EAAE,KAAK,GAAG,OAAO,GAAG,GAAG,KAAK,GAAG,OAAO,GAAG,EAAE;AAEnD;AAEO,SAAS,WAAW,QAAmC;AAC5D,SAAO,OAAO,WAAW,WAAW,CAAC,MAAM,IAAI,CAAC,OAAO,KAAK,OAAO,GAAG;AACxE;AAOO,SAAS,cAAc,IAAY,UAA2B;AACnE,MACE,OAAO,aAAa,YACpB,aAAa,QACb,EAAE,YAAY,WACd;AACA,UAAM;AAAA,MACJ,GAAG,EAAE,sEAAsE,cAAc,QAAQ,CAAC;AAAA,IACpG;AAAA,EACF;AAEA,MAAK,SAA+B,QAAQ,QAAW;AACrD,UAAM;AAAA,MACJ,GAAG,EAAE;AAAA,IACP;AAAA,EACF;AAEA,QAAM,EAAE,OAAO,IAAI;AAEnB,MAAI,OAAO,WAAW,UAAU;AAC9B,QAAI,CAAC,WAAW,MAAM,GAAG;AACvB,YAAM;AAAA,QACJ,GAAG,EAAE,6DAA6D,cAAc,MAAM,CAAC;AAAA,MACzF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,MACE,OAAO,WAAW,YAClB,WAAW,QACX,SAAS,UACT,SAAS,QACT;AACA,UAAM,EAAE,KAAK,IAAI,IAAI;AACrB,QAAI,CAAC,WAAW,GAAG,KAAK,CAAC,WAAW,GAAG,GAAG;AACxC,YAAM;AAAA,QACJ,GAAG,EAAE,uEAAuE,cAAc,GAAG,CAAC,UAAU,cAAc,GAAG,CAAC;AAAA,MAC5H;AAAA,IACF;AACA,QAAI,OAAO,KAAK;AACd,YAAM;AAAA,QACJ,GAAG,EAAE,oBAAoB,GAAG,gCAAgC,GAAG;AAAA,MACjE;AAAA,IACF;AACA,WAAO,EAAE,KAAK,IAAI;AAAA,EACpB;AAEA,QAAM;AAAA,IACJ,GAAG,EAAE,+DAA+D,cAAc,MAAM,CAAC;AAAA,EAC3F;AACF;AAQO,SAAS,YAA8B,IAAY,QAAc;AACtE,aAAW,SAAS,WAAW,MAAM,GAAG;AACtC,QAAI,CAAC,WAAW,KAAK,GAAG;AACtB,YAAM;AAAA,QACJ,GAAG,EAAE,kEAAkE,cAAc,KAAK,CAAC;AAAA,MAC7F;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAOO,SAAS,YAAY,IAAY,MAAc,OAAsB;AAC1E,MAAI,CAAC,WAAW,KAAK,GAAG;AACtB,UAAM;AAAA,MACJ,GAAG,EAAE,KAAK,IAAI,qDAAqD,cAAc,KAAK,CAAC;AAAA,IACzF;AAAA,EACF;AACF;AAEO,SAAS,WAAW,OAAiC;AAC1D,SAAO,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,QAAQ;AACxE;AAEA,SAAS,gBAAgB,SAAkC;AACzD,SAAO,IAAI,gBAAgB,oBAAoB,OAAO;AACxD;","names":[]}