@recipe-book/core 0.0.3 → 0.1.1

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 (185) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +50 -7
  4. package/dist/chunk-D5CV3UF7.cjs +2754 -0
  5. package/dist/chunk-D5CV3UF7.cjs.map +1 -0
  6. package/dist/errors.js +76 -0
  7. package/dist/errors.js.map +1 -0
  8. package/dist/format/duration.js +36 -0
  9. package/dist/format/duration.js.map +1 -0
  10. package/dist/format/ingredient.js +49 -0
  11. package/dist/format/ingredient.js.map +1 -0
  12. package/dist/format/number.js +120 -0
  13. package/dist/format/number.js.map +1 -0
  14. package/dist/format/options.js +31 -0
  15. package/dist/format/options.js.map +1 -0
  16. package/dist/format/quantity.js +144 -0
  17. package/dist/format/quantity.js.map +1 -0
  18. package/dist/format/servings.js +68 -0
  19. package/dist/format/servings.js.map +1 -0
  20. package/dist/format/temperature.js +35 -0
  21. package/dist/format/temperature.js.map +1 -0
  22. package/dist/index.cjs +476 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.d.cts +994 -0
  25. package/dist/index.d.ts +994 -5
  26. package/dist/index.js +48 -1
  27. package/dist/index.js.map +1 -0
  28. package/dist/ingredient/parse.js +412 -0
  29. package/dist/ingredient/parse.js.map +1 -0
  30. package/dist/issues.js +26 -0
  31. package/dist/issues.js.map +1 -0
  32. package/dist/quantity/amount.js +78 -0
  33. package/dist/quantity/amount.js.map +1 -0
  34. package/dist/quantity/parse.js +255 -0
  35. package/dist/quantity/parse.js.map +1 -0
  36. package/dist/quantity/scale.js +23 -0
  37. package/dist/quantity/scale.js.map +1 -0
  38. package/dist/recipe/duration.js +28 -0
  39. package/dist/recipe/duration.js.map +1 -0
  40. package/dist/recipe/ids.js +68 -0
  41. package/dist/recipe/ids.js.map +1 -0
  42. package/dist/recipe/infer.js +24 -0
  43. package/dist/recipe/infer.js.map +1 -0
  44. package/dist/recipe/nutrient-units.js +18 -0
  45. package/dist/recipe/nutrient-units.js.map +1 -0
  46. package/dist/recipe/nutrition.js +26 -0
  47. package/dist/recipe/nutrition.js.map +1 -0
  48. package/dist/recipe/parse.js +854 -0
  49. package/dist/recipe/parse.js.map +1 -0
  50. package/dist/recipe/schema.js +32 -0
  51. package/dist/recipe/schema.js.map +1 -0
  52. package/dist/recipe/url.js +9 -0
  53. package/dist/recipe/url.js.map +1 -0
  54. package/dist/resolve.js +120 -0
  55. package/dist/resolve.js.map +1 -0
  56. package/dist/schema-org/find.js +158 -0
  57. package/dist/schema-org/find.js.map +1 -0
  58. package/dist/schema-org/from-json-ld.js +647 -0
  59. package/dist/schema-org/from-json-ld.js.map +1 -0
  60. package/dist/schema-org/nutrients.js +19 -0
  61. package/dist/schema-org/nutrients.js.map +1 -0
  62. package/dist/schema-org/text.js +125 -0
  63. package/dist/schema-org/text.js.map +1 -0
  64. package/dist/schema-org/to-json-ld.js +151 -0
  65. package/dist/schema-org/to-json-ld.js.map +1 -0
  66. package/dist/schema-org.cjs +1062 -0
  67. package/dist/schema-org.cjs.map +1 -0
  68. package/dist/schema-org.d.cts +223 -0
  69. package/dist/schema-org.d.ts +223 -0
  70. package/dist/schema-org.js +10 -0
  71. package/dist/schema-org.js.map +1 -0
  72. package/dist/steps/segment.js +45 -0
  73. package/dist/steps/segment.js.map +1 -0
  74. package/dist/steps/temperature.js +174 -0
  75. package/dist/steps/temperature.js.map +1 -0
  76. package/dist/suggest.js +50 -0
  77. package/dist/suggest.js.map +1 -0
  78. package/dist/types-BknM8KOd.d.cts +628 -0
  79. package/dist/types-BknM8KOd.d.ts +628 -0
  80. package/dist/units/convert.js +79 -0
  81. package/dist/units/convert.js.map +1 -0
  82. package/dist/units/lookup.js +86 -0
  83. package/dist/units/lookup.js.map +1 -0
  84. package/dist/units/registry.js +183 -0
  85. package/dist/units/registry.js.map +1 -0
  86. package/dist/units/system.js +91 -0
  87. package/dist/units/system.js.map +1 -0
  88. package/dist/units/temperature.js +40 -0
  89. package/dist/units/temperature.js.map +1 -0
  90. package/dist/validation.js +90 -0
  91. package/dist/validation.js.map +1 -0
  92. package/package.json +53 -8
  93. package/schema-org/package.json +5 -0
  94. package/dist/chunk-22RBQQWH.mjs +0 -1
  95. package/dist/chunk-3CSP4UZG.mjs +0 -1
  96. package/dist/chunk-3FFMHRB7.mjs +0 -26
  97. package/dist/chunk-4EB3HG6B.mjs +0 -50
  98. package/dist/chunk-4EHNJLGI.mjs +0 -50
  99. package/dist/chunk-4IA5EEKI.mjs +0 -81
  100. package/dist/chunk-4TATADPT.mjs +0 -51
  101. package/dist/chunk-6GI5KLI4.mjs +0 -22
  102. package/dist/chunk-6QJZOZ6W.mjs +0 -49
  103. package/dist/chunk-6YYQ4RYZ.mjs +0 -26
  104. package/dist/chunk-7BBUDI2L.mjs +0 -0
  105. package/dist/chunk-7KEMNCV7.mjs +0 -26
  106. package/dist/chunk-7MMJZZ7Z.mjs +0 -75
  107. package/dist/chunk-BES4V7DM.mjs +0 -26
  108. package/dist/chunk-BRDXRIAH.mjs +0 -50
  109. package/dist/chunk-C45UD45A.mjs +0 -1
  110. package/dist/chunk-CBLJXA6P.mjs +0 -1
  111. package/dist/chunk-CFR2ZBPB.mjs +0 -77
  112. package/dist/chunk-DYMNVFL2.mjs +0 -78
  113. package/dist/chunk-F6AQKXJU.mjs +0 -51
  114. package/dist/chunk-FW4M2R43.mjs +0 -51
  115. package/dist/chunk-GCCEDUST.mjs +0 -75
  116. package/dist/chunk-GE7KBYZR.mjs +0 -26
  117. package/dist/chunk-GN7ZDGYC.mjs +0 -51
  118. package/dist/chunk-GOO2SCQX.mjs +0 -75
  119. package/dist/chunk-HAVKBQLH.mjs +0 -51
  120. package/dist/chunk-HCBIPANU.mjs +0 -50
  121. package/dist/chunk-IEBAGBS7.mjs +0 -51
  122. package/dist/chunk-IQ6WN2V4.mjs +0 -75
  123. package/dist/chunk-JB4Z2FR7.mjs +0 -77
  124. package/dist/chunk-JMTQOTBM.mjs +0 -75
  125. package/dist/chunk-JRO6JUES.mjs +0 -51
  126. package/dist/chunk-KH3SRXET.mjs +0 -52
  127. package/dist/chunk-KNZMCAQZ.mjs +0 -51
  128. package/dist/chunk-KXFUCO3R.mjs +0 -1
  129. package/dist/chunk-LEBGCVCJ.mjs +0 -75
  130. package/dist/chunk-MAHWFCIL.mjs +0 -51
  131. package/dist/chunk-MPH76SLC.mjs +0 -51
  132. package/dist/chunk-MQGMFBJQ.mjs +0 -26
  133. package/dist/chunk-NMIVG7T4.mjs +0 -6
  134. package/dist/chunk-Q3TT3AZ4.mjs +0 -26
  135. package/dist/chunk-QY5HJQIL.mjs +0 -51
  136. package/dist/chunk-RCPLSNKM.mjs +0 -1
  137. package/dist/chunk-SR2KFBT3.mjs +0 -1
  138. package/dist/chunk-ULDLBAEG.mjs +0 -1
  139. package/dist/chunk-UQ3EUYFR.mjs +0 -1
  140. package/dist/chunk-V26OCCTL.mjs +0 -82
  141. package/dist/chunk-WBQAMGXK.mjs +0 -0
  142. package/dist/chunk-WSW6FMUL.mjs +0 -51
  143. package/dist/chunk-WU55SBIV.mjs +0 -75
  144. package/dist/chunk-XINK45P3.mjs +0 -51
  145. package/dist/chunk-XNOHIWI3.mjs +0 -1
  146. package/dist/chunk-XV4QN4LA.mjs +0 -6
  147. package/dist/chunk-YHBXEVUX.mjs +0 -51
  148. package/dist/chunk-YSZSCEI7.mjs +0 -75
  149. package/dist/chunk-YY3SUQ7P.mjs +0 -16
  150. package/dist/chunk-YZB3IG47.mjs +0 -80
  151. package/dist/chunk-ZPZT4LMQ.mjs +0 -25
  152. package/dist/const/conversion.d.mts +0 -7
  153. package/dist/const/conversion.d.ts +0 -7
  154. package/dist/const/conversion.js +0 -1
  155. package/dist/const/conversion.mjs +0 -1
  156. package/dist/const/unit.d.mts +0 -119
  157. package/dist/const/unit.d.ts +0 -119
  158. package/dist/const/unit.js +0 -1
  159. package/dist/const/unit.mjs +0 -1
  160. package/dist/index.d.mts +0 -5
  161. package/dist/index.mjs +0 -1
  162. package/dist/lib/assert.d.mts +0 -18
  163. package/dist/lib/assert.d.ts +0 -18
  164. package/dist/lib/assert.js +0 -1
  165. package/dist/lib/assert.mjs +0 -1
  166. package/dist/lib/ingredient.d.mts +0 -9
  167. package/dist/lib/ingredient.d.ts +0 -9
  168. package/dist/lib/ingredient.js +0 -1
  169. package/dist/lib/ingredient.mjs +0 -1
  170. package/dist/lib/recipe.d.mts +0 -12
  171. package/dist/lib/recipe.d.ts +0 -12
  172. package/dist/lib/recipe.js +0 -1
  173. package/dist/lib/recipe.mjs +0 -1
  174. package/dist/lib/runtime.d.mts +0 -3
  175. package/dist/lib/runtime.d.ts +0 -3
  176. package/dist/lib/runtime.js +0 -1
  177. package/dist/lib/runtime.mjs +0 -1
  178. package/dist/lib/unit.d.mts +0 -19
  179. package/dist/lib/unit.d.ts +0 -19
  180. package/dist/lib/unit.js +0 -1
  181. package/dist/lib/unit.mjs +0 -1
  182. package/dist/types.d.mts +0 -36
  183. package/dist/types.d.ts +0 -36
  184. package/dist/types.js +0 -1
  185. package/dist/types.mjs +0 -1
package/dist/index.js CHANGED
@@ -1 +1,48 @@
1
- var m=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var R=Object.prototype.hasOwnProperty;var E=(o,e)=>{for(var t in e)m(o,t,{get:e[t],enumerable:!0})},w=(o,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of h(e))!R.call(o,i)&&i!==t&&m(o,i,{get:()=>e[i],enumerable:!(n=d(e,i))||n.enumerable});return o};var I=o=>w(m({},"__esModule",{value:!0}),o);var C={};E(C,{IngredientUtils:()=>T,RecipeUtils:()=>g,UnitUtils:()=>s});module.exports=I(C);function u(o,e,t){if(!e(o))throw new Error(t)}function a(o,e,t){e(o)||console.warn(t)}var p=class{scopedMessageFactory(e){return t=>`${e.name}: ${t}`}validateConfig(e){if(!!0)return;let t=this.scopedMessageFactory(e);a(e.instructions,n=>n.length>0,t("Recipe has no instructions")),a(e.serves,n=>n===void 0||n>0,t("Recipe serves less than 1 person")),a(e.ingredients,n=>n.length>0,t("Recipe has no ingredients"))}determineMeasurementSystem(e){let{ingredients:t}=e,n=t.some(r=>r.amount.unit==="g"),i=t.some(r=>r.amount.unit==="oz"||r.amount.unit==="fl oz"||r.amount.unit==="lb"||r.amount.unit==="cup");if(!(n&&i)){if(n)return"metric";if(i)return"us-customary"}}create(e){return this.validateConfig(e),{name:e.name,ingredients:e.ingredients,thumbnail:e.thumbnail,serves:e.serves??1,instructions:e.instructions??[],measurements:e.measurements??this.determineMeasurementSystem(e)}}},g=new p;var y={g:{g:1,kg:1e3,oz:28.3495,lb:453.592},kg:{g:.001,kg:1,oz:.0283495,lb:.453592},oz:{g:.035274,kg:35.274,oz:1,lb:16},lb:{g:.00220462,kg:2.20462,oz:.0625,lb:1}},f={L:{L:1,gal:3.78541,mL:.001,cup:.0625,tsp:.202884,tbsp:.067628},gal:{L:.264172,gal:1,mL:264172e-9,cup:.016,tsp:.0515037,tbsp:.0171679},mL:{L:1e3,gal:3785.41,mL:1,cup:.00422675,tsp:.033814,tbsp:.00422675},cup:{L:4.22675,gal:16,mL:236.588,cup:1,tsp:3.2,tbsp:1.06667},tsp:{L:4.92892,gal:18.6184,mL:4.92892,cup:.3125,tsp:1,tbsp:.333333},tbsp:{L:14.7868,gal:56,mL:14.7868,cup:.9375,tsp:3,tbsp:1}};var N=[{key:!0,name:"gram",abbreviation:"g",value:1,type:"weight"},{name:"kilogram",abbreviation:"kg",value:1e3,type:"weight"},{name:"ounce",abbreviation:"oz",value:28.3495,type:"weight"},{name:"pound",abbreviation:"lb",value:453.592,type:"weight"}],x=[{key:!0,name:"liter",abbreviation:"L",value:1,type:"volume"},{name:"gallon",abbreviation:"gal",value:3.78541,type:"volume"},{name:"milliliter",abbreviation:"mL",value:.001,type:"volume"},{name:"cup",abbreviation:"cup",value:.0625,type:"volume"},{name:"teaspoon",abbreviation:"tsp",value:.0208333,type:"volume"},{name:"tablespoon",abbreviation:"tbsp",value:.0625,type:"volume"},{name:"fluid ounce",abbreviation:"fl oz",value:.125,type:"volume"}],L=[...N,...x];var l=class{getUnit(e){let t=L.find(n=>n.abbreviation===e||n.name===e);return u(t,n=>!!n,`No unit found for key: ${e}`),t}getConversionRatio(e,t){let{type:n,abbreviation:i}=this.getUnit(e),b=(n==="volume"?f:y)[i],v=t==="metric"?n==="volume"?"L":"g":t;return u(b,U=>!!U[v],`No conversion table available for unit ${e} to ${t}`),b[v]}convert(e,t,n){if(t===n)return e;let i=this.getConversionRatio(t,n),r=e*i;return Math.round(r*100)/100}},s=new l;var c=class{convert(e,t){let n=s.convert(e.amount.value,e.amount.unit,t);return{...e,amount:{value:n,unit:t}}}},T=new c;0&&(module.exports={IngredientUtils,RecipeUtils,UnitUtils});
1
+ // src/index.ts
2
+ import { parseRecipe } from "./recipe/parse.js";
3
+ import { parseQuantity } from "./quantity/parse.js";
4
+ import { parseIngredientLine } from "./ingredient/parse.js";
5
+ import { createRecipeSchema, recipeSchema } from "./recipe/schema.js";
6
+ import { resolveIngredient, resolveRecipe } from "./resolve.js";
7
+ import { scaleQuantity } from "./quantity/scale.js";
8
+ import { canConvert, convert, toSystem } from "./units/convert.js";
9
+ import { findUnit, getUnit } from "./units/lookup.js";
10
+ import { units } from "./units/registry.js";
11
+ import { convertTemperature } from "./units/temperature.js";
12
+ import { formatQuantity, formatQuantityToParts } from "./format/quantity.js";
13
+ import { formatIngredient } from "./format/ingredient.js";
14
+ import { formatServings, formatYield } from "./format/servings.js";
15
+ import { formatTemperature } from "./format/temperature.js";
16
+ import { formatDuration } from "./format/duration.js";
17
+ import { segmentStepText } from "./steps/segment.js";
18
+ import { isRecipeBookError, RecipeBookError } from "./errors.js";
19
+ import { RecipeValidationError } from "./validation.js";
20
+ export {
21
+ RecipeBookError,
22
+ RecipeValidationError,
23
+ canConvert,
24
+ convert,
25
+ convertTemperature,
26
+ createRecipeSchema,
27
+ findUnit,
28
+ formatDuration,
29
+ formatIngredient,
30
+ formatQuantity,
31
+ formatQuantityToParts,
32
+ formatServings,
33
+ formatTemperature,
34
+ formatYield,
35
+ getUnit,
36
+ isRecipeBookError,
37
+ parseIngredientLine,
38
+ parseQuantity,
39
+ parseRecipe,
40
+ recipeSchema,
41
+ resolveIngredient,
42
+ resolveRecipe,
43
+ scaleQuantity,
44
+ segmentStepText,
45
+ toSystem,
46
+ units
47
+ };
48
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,412 @@
1
+ // src/ingredient/parse.ts
2
+ import {
3
+ describeValue,
4
+ quote
5
+ } from "../errors.js";
6
+ import { warning } from "../issues.js";
7
+ import { isPositive } from "../quantity/amount.js";
8
+ import { matchAmount } from "../quantity/parse.js";
9
+ import { findUnit } from "../units/lookup.js";
10
+ import { units } from "../units/registry.js";
11
+ import { error, toResult } from "../validation.js";
12
+ var NUMBER_WORDS = "one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve";
13
+ var DASH = "[ \u2013\u2014-]";
14
+ var OPTIONAL = /(?:\(optional\)|optional\b ?:?) ?/iy;
15
+ var ABOUT = /(about|approx(?:imately|\.)?) /iy;
16
+ var PHRASE = /((?:an? )?(?:handful|few|couple|little|splash|drizzle|knob|squeeze|sprinkle|dollop))(?: of)? /iy;
17
+ var HYPHENATED_MIXED = /^(\d+)-(?=(\d+)[/⁄](\d+)(?![\d/⁄]))/;
18
+ var AMOUNT_CHARS = /(?:an? )?(?:[\d .,/⁄¼-¾⅐-⅞–—-]|to )*/iy;
19
+ var TIMES = / ?[x×] ?(?=\d)/iy;
20
+ var UNREAD = / ?(?:(?:or|to|and|plus|half)(?![\p{L}\p{M}\d_])|[-–—/+&]|\d+(?:\.\d+)?(?= |$))/iuy;
21
+ var UNREAD_AFTER_UNIT = / (?:or|to|[-–—&])(?= )|[-–—+&](?=\d)/iy;
22
+ var PAREN = / ?\(([^()]*)\)/y;
23
+ var PARENS = / ?\(([^()]*)\)/g;
24
+ var MODIFIER = / ?(heaping|heaped|level|scant|rounded|generous|good|small|medium|large|big)(?= )/iy;
25
+ var JOIN = /(?: (plus|and|\+)(?![\p{L}\p{M}]))? ?/iuy;
26
+ var SEPARATOR = / ?[;/] ?/y;
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
+ 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
+ var OPTIONAL_NOTE = /(?:^|,) ?optional(?= ?(?:,|$))/gi;
31
+ var OPTIONAL_PAREN = /^optional\b[,;:]? ?/i;
32
+ var CONTAINER = /^(?:stalk|rib|ear|sheet|loa(?:f|ves)|knob|strip|bag|box|jar|bottle|packet|envelope|tin|block|ball|handful)(?:e?s)?$/i;
33
+ var BONELESS = /(?:^|[^a-z])(?:bone|skin)less$/i;
34
+ var patterns;
35
+ function getPatterns() {
36
+ return patterns ?? (patterns = compilePatterns());
37
+ }
38
+ function compilePatterns() {
39
+ const word = String.raw`([a-z]+(?:-[a-z]+)?)\.?`;
40
+ const end = String.raw`(?=[ ,;()/]|[-–—+&]\d|$)`;
41
+ return {
42
+ count: new RegExp(`(an?|${NUMBER_WORDS}) (?=\\S)`, "iy"),
43
+ unit: new RegExp(`${DASH}?${word}${end}`, "iy"),
44
+ twoWordUnit: new RegExp(`${DASH}?${word} ${word}${end}`, "iy")
45
+ };
46
+ }
47
+ function parseIngredientLine(text) {
48
+ const issues = [];
49
+ if (typeof text !== "string") {
50
+ error(
51
+ issues,
52
+ "invalid_type",
53
+ [],
54
+ `Expected ingredient text such as "2 cups flour", received ${describeValue(text)}.`
55
+ );
56
+ return toResult("invalid_ingredient", void 0, issues);
57
+ }
58
+ const line = text.replace(/[\s\u200b]+/g, " ").trim();
59
+ if (line === "") {
60
+ error(
61
+ issues,
62
+ "missing_field",
63
+ [],
64
+ 'Add the ingredient text, e.g. "2 cups flour".'
65
+ );
66
+ return toResult(
67
+ "invalid_ingredient",
68
+ void 0,
69
+ issues,
70
+ quote(text)
71
+ );
72
+ }
73
+ let ingredient = readLine(line);
74
+ if (isProblem(ingredient)) {
75
+ warning(
76
+ issues,
77
+ ingredient.code,
78
+ [],
79
+ `${ingredient.message} The whole line is kept as the name.`
80
+ );
81
+ ingredient = { name: line };
82
+ }
83
+ return toResult("invalid_ingredient", ingredient, issues);
84
+ }
85
+ function readLine(line) {
86
+ const p = getPatterns();
87
+ const notes = [];
88
+ const equivalents = [];
89
+ let optional = false;
90
+ let quantity;
91
+ let amount;
92
+ let unitText = "";
93
+ let i = 0;
94
+ let match;
95
+ const eat = (pattern) => {
96
+ const found = exec(pattern, line, i);
97
+ if (found) {
98
+ i = pattern.lastIndex;
99
+ }
100
+ return found;
101
+ };
102
+ const addNote = (text) => {
103
+ const note = /[^ ,](?:.*[^ ,])?/s.exec(text);
104
+ if (note) {
105
+ notes.push(note[0]);
106
+ }
107
+ };
108
+ const readParen = (content, keep) => {
109
+ const text = content.replace(OPTIONAL_PAREN, () => {
110
+ optional = true;
111
+ return "";
112
+ });
113
+ const measures = text ? readMeasures(text) : void 0;
114
+ if (measures) {
115
+ quantity ?? (quantity = measures.shift());
116
+ for (const measure of measures) {
117
+ equivalents.push(measure);
118
+ }
119
+ return "";
120
+ }
121
+ if (keep && text) {
122
+ return ` (${text})`;
123
+ }
124
+ addNote(text);
125
+ return "";
126
+ };
127
+ if (eat(OPTIONAL)) {
128
+ optional = true;
129
+ }
130
+ match = exec(ABOUT, line, i);
131
+ if (match && readAmount(line, ABOUT.lastIndex)) {
132
+ addNote(match[1]);
133
+ i = ABOUT.lastIndex;
134
+ }
135
+ if (match = eat(PHRASE)) {
136
+ addNote(match[1]);
137
+ } else if (match = eat(p.count)) {
138
+ amount = NUMBER_WORDS.split("|").indexOf(match[1].toLowerCase()) + 1 || 1;
139
+ } else {
140
+ const found = readAmount(line, i, true);
141
+ if (isProblem(found)) {
142
+ return found;
143
+ }
144
+ if (found) {
145
+ ;
146
+ [amount, i] = found;
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
+ }
163
+ }
164
+ }
165
+ }
166
+ if (amount !== void 0) {
167
+ let each;
168
+ eat(TIMES);
169
+ if (match = eat(PAREN)) {
170
+ each = readMeasures(match[1]);
171
+ if (!each) {
172
+ readParen(match[1], false);
173
+ }
174
+ } else {
175
+ const measure = readMeasure(line, i, false);
176
+ if (measure) {
177
+ each = [measure[0]];
178
+ i = measure[1];
179
+ }
180
+ }
181
+ const start = i;
182
+ const modifier = eat(MODIFIER);
183
+ const unit = readUnit(line, i);
184
+ if (unit) {
185
+ if (modifier) {
186
+ addNote(modifier[1]);
187
+ }
188
+ unitText = line.slice(i, unit[1]);
189
+ quantity = { amount, unit: unit[0] };
190
+ i = unit[1];
191
+ const size = !each && units[unit[0]].dimension === "count" && readMeasure(line, i, false);
192
+ if (size) {
193
+ equivalents.push(size[0]);
194
+ i = size[1];
195
+ } else if (!each) {
196
+ const extended = extend(line, [quantity, i]);
197
+ if (isProblem(extended)) {
198
+ return extended;
199
+ }
200
+ ;
201
+ [quantity, i] = extended;
202
+ }
203
+ while (match = eat(SEPARATOR)) {
204
+ const measure = readMeasure(line, i, true);
205
+ if (!measure) {
206
+ return noMeasure(quote(match[0].trim()), "100 g/3\xBD oz");
207
+ }
208
+ equivalents.push(measure[0]);
209
+ i = measure[1];
210
+ }
211
+ } else {
212
+ i = start;
213
+ quantity = { amount };
214
+ }
215
+ if (exec(unit ? UNREAD_AFTER_UNIT : UNREAD, line, i)) {
216
+ return {
217
+ code: "invalid_quantity",
218
+ message: `Couldn't read the quantity in ${quote(line)}. Write one amount or range before the unit, e.g. "2-3 eggs" or "2 \xBD cups".`
219
+ };
220
+ }
221
+ for (const item of each ?? []) {
222
+ const total = combine(amount, item.amount, (a, b) => a * b);
223
+ if (isProblem(total)) {
224
+ return total;
225
+ }
226
+ equivalents.push({ ...item, amount: total });
227
+ }
228
+ }
229
+ const of = quantity !== void 0 && eat(OF);
230
+ const rest = line.slice(i).trim();
231
+ const comma = topLevelComma(rest);
232
+ let name = rest.slice(0, comma).replace(PARENS, (_, content) => readParen(content, false));
233
+ const phrase = PHRASE_NOTE.exec(name);
234
+ if (phrase) {
235
+ addNote(name.slice(phrase.index));
236
+ name = name.slice(0, phrase.index);
237
+ }
238
+ addNote(
239
+ rest.slice(comma + 1).replace(PARENS, (_, content) => readParen(content, true)).replace(OPTIONAL_NOTE, () => {
240
+ optional = true;
241
+ return "";
242
+ })
243
+ );
244
+ name = name.trim();
245
+ if (!name) {
246
+ if (of || !unitText || !quantity?.unit || units[quantity.unit].dimension !== "count") {
247
+ return {
248
+ code: "missing_field",
249
+ message: 'Add a name after the quantity, e.g. "2 cups flour".'
250
+ };
251
+ }
252
+ name = unitText.replace(/^[ –—-]/, "");
253
+ quantity = { amount: quantity.amount };
254
+ }
255
+ let plural;
256
+ if (quantity && !quantity.unit) {
257
+ const many = typeof quantity.amount !== "number" || quantity.amount > 1;
258
+ [name, plural] = countForms(name, many);
259
+ }
260
+ return {
261
+ name,
262
+ ...plural !== void 0 && { plural },
263
+ ...quantity && { quantity },
264
+ ...equivalents.length > 0 && { equivalents },
265
+ ...notes.length > 0 && { note: notes.join(", ") },
266
+ ...optional && { optional }
267
+ };
268
+ }
269
+ function exec(pattern, text, start) {
270
+ pattern.lastIndex = start;
271
+ return pattern.exec(text);
272
+ }
273
+ function readAmount(text, start, whole) {
274
+ exec(AMOUNT_CHARS, text, start);
275
+ const rest = text.slice(start, whole ? void 0 : AMOUNT_CHARS.lastIndex + 1).replace(
276
+ HYPHENATED_MIXED,
277
+ (dash, whole2, numerator, denominator) => Number(numerator) < Number(denominator) ? `${whole2} ` : dash
278
+ );
279
+ const comma = rest.search(/\d,\d/);
280
+ const head = comma < 0 ? rest : rest.slice(0, comma).trimEnd();
281
+ const match = matchAmount(head);
282
+ if (comma >= 0 && (match ? !("code" in match) && !match.rest : !head)) {
283
+ return matchAmount(rest);
284
+ }
285
+ if (!match || "code" in match) {
286
+ return match;
287
+ }
288
+ return [match.amount, start + head.length - match.rest.length];
289
+ }
290
+ function isProblem(value) {
291
+ return typeof value === "object" && value !== null && "code" in value;
292
+ }
293
+ function readUnit(text, start) {
294
+ const { twoWordUnit, unit } = getPatterns();
295
+ for (const pattern of [twoWordUnit, unit]) {
296
+ const match = exec(pattern, text, start);
297
+ const id = match && findUnit(match.slice(1).join(" "));
298
+ if (id) {
299
+ return [id, pattern.lastIndex];
300
+ }
301
+ }
302
+ return void 0;
303
+ }
304
+ function readMeasure(text, start, compound) {
305
+ const amount = readAmount(text, text[start] === " " ? start + 1 : start);
306
+ if (!amount || isProblem(amount)) {
307
+ return void 0;
308
+ }
309
+ const unit = readUnit(text, amount[1]);
310
+ if (!unit) {
311
+ return void 0;
312
+ }
313
+ const measure = [
314
+ { amount: amount[0], unit: unit[0] },
315
+ unit[1]
316
+ ];
317
+ const extended = compound ? extend(text, measure) : measure;
318
+ return isProblem(extended) ? void 0 : extended;
319
+ }
320
+ function extend(text, measure) {
321
+ const [{ amount, unit }, end] = measure;
322
+ const connector = exec(JOIN, text, end)[1];
323
+ const extra = readMeasure(text, JOIN.lastIndex, false);
324
+ const from = units[unit];
325
+ const to = extra && units[extra[0].unit];
326
+ if (!from.toBase || !to?.toBase || from.dimension !== to.dimension) {
327
+ return connector ? noMeasure(
328
+ `${quote(connector)} in the same kind of unit`,
329
+ "1 cup plus 2 tbsp"
330
+ ) : measure;
331
+ }
332
+ const ratio = to.toBase / from.toBase;
333
+ const total = combine(amount, extra[0].amount, (a, b) => a + b * ratio);
334
+ return isProblem(total) ? total : [{ amount: total, unit }, extra[1]];
335
+ }
336
+ function noMeasure(after, example) {
337
+ return {
338
+ code: "invalid_quantity",
339
+ message: `Expected a measure after ${after}, e.g. "${example}".`
340
+ };
341
+ }
342
+ function readMeasures(text) {
343
+ const measures = [];
344
+ let i = 0;
345
+ for (; ; ) {
346
+ const measure = readMeasure(text, i, true);
347
+ if (!measure) {
348
+ return void 0;
349
+ }
350
+ measures.push(measure[0]);
351
+ i = measure[1];
352
+ if (i === text.length) {
353
+ return measures;
354
+ }
355
+ if (!exec(SEPARATOR, text, i)) {
356
+ return void 0;
357
+ }
358
+ i = SEPARATOR.lastIndex;
359
+ }
360
+ }
361
+ function combine(a, b, op) {
362
+ const [aMin, aMax] = typeof a === "number" ? [a, a] : [a.min, a.max];
363
+ const [bMin, bMax] = typeof b === "number" ? [b, b] : [b.min, b.max];
364
+ const min = op(aMin, bMin);
365
+ const max = op(aMax, bMax);
366
+ if (!isPositive(min) || !isPositive(max)) {
367
+ return {
368
+ code: "invalid_amount",
369
+ message: 'The total amount is out of range. Write it as one amount, e.g. "800 g".'
370
+ };
371
+ }
372
+ return min < max ? { min, max } : min;
373
+ }
374
+ function topLevelComma(text) {
375
+ let depth = 0;
376
+ for (let j = 0; j < text.length; j++) {
377
+ const char = text[j];
378
+ if (char === "(") {
379
+ depth++;
380
+ } else if (char === ")") {
381
+ depth = Math.max(0, depth - 1);
382
+ } else if (char === "," && depth === 0 && !BONELESS.test(text.slice(0, j).slice(-9))) {
383
+ return j;
384
+ }
385
+ }
386
+ return text.length;
387
+ }
388
+ function countForms(name, many) {
389
+ const words = name.split(" ");
390
+ let k = words.indexOf("of") - 1;
391
+ if (k < 0) {
392
+ k = CONTAINER.test(words[0]) ? 0 : words.length - 1;
393
+ }
394
+ const word = words[k];
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
+ );
402
+ if (other === word) {
403
+ return [name, void 0];
404
+ }
405
+ words[k] = other;
406
+ const swapped = words.join(" ");
407
+ return many ? [swapped, name] : [name, swapped];
408
+ }
409
+ export {
410
+ parseIngredientLine
411
+ };
412
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +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 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 ADDED
@@ -0,0 +1,26 @@
1
+ // src/issues.ts
2
+ function warning(issues, code, path, message, suggestion) {
3
+ issues.push(issue("warning", code, path, message, suggestion));
4
+ }
5
+ function issue(severity, code, path, message, suggestion) {
6
+ return {
7
+ code,
8
+ severity,
9
+ path,
10
+ message,
11
+ ...suggestion !== void 0 && { suggestion }
12
+ };
13
+ }
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function hasOwn(record, key) {
18
+ return Object.prototype.hasOwnProperty.call(record, key);
19
+ }
20
+ export {
21
+ hasOwn,
22
+ isRecord,
23
+ issue,
24
+ warning
25
+ };
26
+ //# sourceMappingURL=issues.js.map
@@ -0,0 +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 | 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":[]}
@@ -0,0 +1,78 @@
1
+ // src/quantity/amount.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ function mapAmount(amount, fn) {
4
+ return typeof amount === "number" ? fn(amount) : { min: fn(amount.min), max: fn(amount.max) };
5
+ }
6
+ function amountEnds(amount) {
7
+ return typeof amount === "number" ? [amount] : [amount.min, amount.max];
8
+ }
9
+ function checkQuantity(fn, quantity) {
10
+ if (typeof quantity !== "object" || quantity === null || !("amount" in quantity)) {
11
+ throw invalidArgument(
12
+ `${fn}: expected a quantity such as { amount: 2, unit: "cup" }, received ${describeValue(quantity)}.`
13
+ );
14
+ }
15
+ if (quantity.max !== void 0) {
16
+ throw invalidArgument(
17
+ `${fn}: a quantity has no max; use amount: { min, max }, or formatServings or formatYield.`
18
+ );
19
+ }
20
+ const { amount } = quantity;
21
+ if (typeof amount === "number") {
22
+ if (!isPositive(amount)) {
23
+ throw invalidArgument(
24
+ `${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`
25
+ );
26
+ }
27
+ return amount;
28
+ }
29
+ if (typeof amount === "object" && amount !== null && "min" in amount && "max" in amount) {
30
+ const { min, max } = amount;
31
+ if (!isPositive(min) || !isPositive(max)) {
32
+ throw invalidArgument(
33
+ `${fn}: range ends must be finite numbers greater than 0, received { min: ${describeValue(min)}, max: ${describeValue(max)} }.`
34
+ );
35
+ }
36
+ if (min >= max) {
37
+ throw invalidArgument(
38
+ `${fn}: range minimum (${min}) must be less than maximum (${max}). Use a plain number when both ends are equal.`
39
+ );
40
+ }
41
+ return { min, max };
42
+ }
43
+ throw invalidArgument(
44
+ `${fn}: amount must be a number or a { min, max } range, received ${describeValue(amount)}.`
45
+ );
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
+ }
57
+ function checkFactor(fn, name, value) {
58
+ if (!isPositive(value)) {
59
+ throw invalidArgument(
60
+ `${fn}: ${name} must be a finite number greater than 0, received ${describeValue(value)}.`
61
+ );
62
+ }
63
+ }
64
+ function isPositive(value) {
65
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
66
+ }
67
+ function invalidArgument(message) {
68
+ return new RecipeBookError("invalid_argument", message);
69
+ }
70
+ export {
71
+ amountEnds,
72
+ checkFactor,
73
+ checkQuantity,
74
+ checkResult,
75
+ isPositive,
76
+ mapAmount
77
+ };
78
+ //# sourceMappingURL=amount.js.map