@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.
- package/CHANGELOG.md +138 -0
- package/README.md +1 -1
- package/dist/{chunk-VWVIBCTG.cjs → chunk-ISRDJR2O.cjs} +227 -179
- package/dist/chunk-ISRDJR2O.cjs.map +1 -0
- package/dist/errors.js +29 -1
- package/dist/errors.js.map +1 -1
- package/dist/format/duration.js.map +1 -1
- package/dist/format/ingredient.js.map +1 -1
- package/dist/format/number.js +1 -1
- package/dist/format/number.js.map +1 -1
- package/dist/format/options.js.map +1 -1
- package/dist/format/quantity.js.map +1 -1
- package/dist/format/servings.js.map +1 -1
- package/dist/format/temperature.js.map +1 -1
- package/dist/index.cjs +87 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -28
- package/dist/index.d.ts +71 -28
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/ingredient/parse.js +27 -22
- package/dist/ingredient/parse.js.map +1 -1
- package/dist/issues.js.map +1 -1
- package/dist/quantity/amount.js +11 -0
- package/dist/quantity/amount.js.map +1 -1
- package/dist/quantity/parse.js +58 -81
- package/dist/quantity/parse.js.map +1 -1
- package/dist/quantity/scale.js +5 -2
- package/dist/quantity/scale.js.map +1 -1
- package/dist/recipe/ids.js.map +1 -1
- package/dist/recipe/parse.js +80 -60
- package/dist/recipe/parse.js.map +1 -1
- package/dist/recipe/schema.js +8 -6
- package/dist/recipe/schema.js.map +1 -1
- package/dist/resolve.js +6 -1
- package/dist/resolve.js.map +1 -1
- package/dist/schema-org/find.js +46 -13
- package/dist/schema-org/find.js.map +1 -1
- package/dist/schema-org/from-json-ld.js +134 -71
- package/dist/schema-org/from-json-ld.js.map +1 -1
- package/dist/schema-org/text.js.map +1 -1
- package/dist/schema-org/to-json-ld.js +12 -15
- package/dist/schema-org/to-json-ld.js.map +1 -1
- package/dist/schema-org.cjs +239 -147
- package/dist/schema-org.cjs.map +1 -1
- package/dist/schema-org.d.cts +35 -21
- package/dist/schema-org.d.ts +35 -21
- package/dist/steps/segment.js.map +1 -1
- package/dist/steps/temperature.js +13 -13
- package/dist/steps/temperature.js.map +1 -1
- package/dist/{types-DSMf45H5.d.cts → types-CKIoWsz8.d.cts} +25 -7
- package/dist/{types-DSMf45H5.d.ts → types-CKIoWsz8.d.ts} +25 -7
- package/dist/units/convert.js +16 -8
- package/dist/units/convert.js.map +1 -1
- package/dist/units/temperature.js +7 -1
- package/dist/units/temperature.js.map +1 -1
- package/dist/validation.js +1 -1
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- 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-
|
|
2
|
-
export { C as CustomTime, D as Dimension, E as Equipment, g as IngredientSection, N as Note, h as Nutrition, i as
|
|
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
|
|
26
|
-
*
|
|
27
|
-
* `quantity: { amount: 1, unit: '
|
|
28
|
-
* `'
|
|
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
|
|
468
|
-
* the `servings` option sets
|
|
469
|
-
* no `max`; with `scale`, both ends of a range
|
|
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
|
|
474
|
-
* yield is the scaling base and the `servings`
|
|
475
|
-
*
|
|
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
|
|
504
|
-
*
|
|
505
|
-
*
|
|
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`,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
703
|
-
* 0.1; only numbers follow the locale, and they
|
|
704
|
-
* in other languages come from the runtime's
|
|
705
|
-
*
|
|
706
|
-
* runtime doesn't support falls back to
|
|
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 {
|
|
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 {
|
|
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":[]}
|
package/dist/ingredient/parse.js
CHANGED
|
@@ -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)\
|
|
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|\+)(?![
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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 ?
|
|
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":"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","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"]}
|
package/dist/issues.js.map
CHANGED
|
@@ -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,
|
|
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":[]}
|
package/dist/quantity/amount.js
CHANGED
|
@@ -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":[]}
|