@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/errors.js ADDED
@@ -0,0 +1,76 @@
1
+ // src/errors.ts
2
+ var DOCS_ORIGIN = "https://recipe-book.siraj.page";
3
+ var ERROR_CODES = /* @__PURE__ */ new Set([
4
+ "invalid_recipe",
5
+ "invalid_quantity",
6
+ "invalid_ingredient",
7
+ "invalid_argument",
8
+ "incompatible_units",
9
+ "unknown_unit",
10
+ "missing_parent"
11
+ ]);
12
+ var ERROR_NAMES = /* @__PURE__ */ new Set([
13
+ "RecipeBookError",
14
+ "RecipeValidationError"
15
+ ]);
16
+ var RecipeBookError = class extends Error {
17
+ constructor(code, message, options) {
18
+ const docsUrl = `${DOCS_ORIGIN}/errors/${code}`;
19
+ super(`${message}
20
+ See ${docsUrl}`, options);
21
+ this.name = "RecipeBookError";
22
+ this.code = code;
23
+ this.docsUrl = docsUrl;
24
+ }
25
+ };
26
+ function isRecipeBookError(error) {
27
+ if (typeof error !== "object" || error === null) {
28
+ return false;
29
+ }
30
+ try {
31
+ const { name, code } = error;
32
+ return ERROR_NAMES.has(name) && ERROR_CODES.has(code);
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ var MAX_ECHOED = 60;
38
+ function describeValue(value) {
39
+ if (typeof value === "string") {
40
+ return quote(value);
41
+ }
42
+ if (typeof value === "function") {
43
+ return "a function";
44
+ }
45
+ if (value === null || typeof value !== "object") {
46
+ return quote(String(value)).slice(1, -1);
47
+ }
48
+ return Array.isArray(value) ? "an array" : "an object";
49
+ }
50
+ var UNESCAPED_BY_JSON = /[\x80-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g;
51
+ function quote(text) {
52
+ return JSON.stringify(truncate(text)).replace(
53
+ UNESCAPED_BY_JSON,
54
+ (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
55
+ );
56
+ }
57
+ function truncate(text) {
58
+ if (text.length <= MAX_ECHOED) {
59
+ return text;
60
+ }
61
+ let end = MAX_ECHOED - 1;
62
+ const last = text.charCodeAt(end - 1);
63
+ if (last >= 55296 && last <= 56319) {
64
+ end--;
65
+ }
66
+ return `${text.slice(0, end)}\u2026`;
67
+ }
68
+ export {
69
+ MAX_ECHOED,
70
+ RecipeBookError,
71
+ describeValue,
72
+ isRecipeBookError,
73
+ quote,
74
+ truncate
75
+ };
76
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts"],"sourcesContent":["import type { RecipeValidationError } from './validation'\n\nconst DOCS_ORIGIN = 'https://recipe-book.siraj.page'\n\n/**\n * Codes for errors thrown by Recipe Book. May grow in minor releases.\n *\n * @see https://recipe-book.siraj.page/errors\n */\nexport type RecipeBookErrorCode =\n | 'invalid_recipe'\n | 'invalid_quantity'\n | 'invalid_ingredient'\n | 'invalid_argument'\n | 'incompatible_units'\n | 'unknown_unit'\n | 'missing_parent'\n\n/** Codes for problems found in recipe data. May grow in minor releases. */\nexport type RecipeIssueCode =\n | 'invalid_type'\n | 'missing_field'\n | 'unknown_unit'\n | 'invalid_quantity'\n | 'invalid_amount'\n | 'invalid_range'\n | 'invalid_duration'\n | 'invalid_url'\n | 'unknown_field'\n | 'duplicate_id'\n | 'empty_list'\n | 'ignored_value'\n\n/** A problem found in recipe data. Data problems are returned as values, never thrown one by one. */\nexport type RecipeIssue = {\n code: RecipeIssueCode\n severity: 'error' | 'warning'\n /** Location in the input, e.g. `['ingredients', 0, 'quantity']`. */\n path: readonly (string | number)[]\n /** A complete sentence that names the fix. */\n message: string\n /** The value the input most likely meant, for \"did you mean\" hints. */\n suggestion?: string | undefined\n}\n\nconst ERROR_CODES: ReadonlySet<unknown> = new Set<RecipeBookErrorCode>([\n 'invalid_recipe',\n 'invalid_quantity',\n 'invalid_ingredient',\n 'invalid_argument',\n 'incompatible_units',\n 'unknown_unit',\n 'missing_parent',\n])\n\nconst ERROR_NAMES: ReadonlySet<unknown> = new Set([\n 'RecipeBookError',\n 'RecipeValidationError',\n])\n\n/**\n * Thrown for programmer errors, such as converting cups to grams or passing an\n * unknown unit id. Each error has a stable `code` and a `docsUrl` that explains the fix.\n *\n * Use {@link isRecipeBookError} rather than `instanceof` to detect it, so the\n * check also works when two copies of the package are installed.\n */\nexport class RecipeBookError extends Error {\n // Declared and assigned in the constructor, so the build needs no class-field helpers.\n declare readonly name: string\n declare readonly code: RecipeBookErrorCode\n declare readonly docsUrl: string\n\n constructor(\n code: RecipeBookErrorCode,\n message: string,\n options?: { cause?: unknown },\n ) {\n const docsUrl = `${DOCS_ORIGIN}/errors/${code}`\n super(`${message}\\nSee ${docsUrl}`, options)\n this.name = 'RecipeBookError'\n this.code = code\n this.docsUrl = docsUrl\n }\n}\n\n/** Codes for `RecipeValidationError`. May grow in minor releases. */\nexport type ValidationErrorCode =\n 'invalid_recipe' | 'invalid_quantity' | 'invalid_ingredient'\n\n/**\n * What `parseRecipe`, `parseQuantity`, and `parseIngredientLine` return. They\n * never throw for bad data: check `success`, or `throw result.error` to fail\n * loudly.\n *\n * @example\n * const result = parseRecipe(json)\n * if (!result.success) throw result.error // message lists the first 10 issues with their paths\n * result.data // Recipe\n */\nexport type ParseResult<T> =\n | { success: true; data: T; warnings: RecipeIssue[] }\n | { success: false; error: RecipeValidationError }\n\n/** True for errors thrown by any copy of Recipe Book. Checks `name` and `code`, not `instanceof`. */\nexport function isRecipeBookError(error: unknown): error is RecipeBookError {\n if (typeof error !== 'object' || error === null) {\n return false\n }\n\n try {\n const { name, code }: { name?: unknown; code?: unknown } = error\n\n return ERROR_NAMES.has(name) && ERROR_CODES.has(code)\n } catch {\n return false\n }\n}\n\n// Messages end up in logs, so they stay bounded however large the input is.\nexport const MAX_ECHOED = 60\n\n/**\n * Describes a value for an error message, short however large the value is:\n * strings are quoted and cut, functions and objects named.\n */\nexport function describeValue(value: unknown): string {\n if (typeof value === 'string') {\n return quote(value)\n }\n\n // A function's source could be any length.\n if (typeof value === 'function') {\n return 'a function'\n }\n\n // A symbol's description can hold any text, newlines included.\n if (value === null || typeof value !== 'object') {\n return quote(String(value)).slice(1, -1)\n }\n\n return Array.isArray(value) ? 'an array' : 'an object'\n}\n\n// Log viewers and editors break lines at U+0085, U+2028 and U+2029, terminals act\n// on other C1 controls, and bidi controls reorder the text around them.\nconst UNESCAPED_BY_JSON =\n /[\\x80-\\x9f\\u061c\\u200e\\u200f\\u2028-\\u202e\\u2066-\\u2069]/g\n\n/**\n * Quotes input text for a message: escaped like a JSON string, so newlines\n * can't fake extra log lines, and cut to 60 UTF-16 code units.\n */\nexport function quote(text: string): string {\n return JSON.stringify(truncate(text)).replace(\n UNESCAPED_BY_JSON,\n (char) => `\\\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,\n )\n}\n\nexport function truncate(text: string): string {\n if (text.length <= MAX_ECHOED) {\n return text\n }\n\n let end = MAX_ECHOED - 1\n const last = text.charCodeAt(end - 1)\n if (last >= 0xd800 && last <= 0xdbff) {\n end--\n }\n return `${text.slice(0, end)}…`\n}\n"],"mappings":";AAEA,IAAM,cAAc;AA2CpB,IAAM,cAAoC,oBAAI,IAAyB;AAAA,EACrE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,cAAoC,oBAAI,IAAI;AAAA,EAChD;AAAA,EACA;AACF,CAAC;AASM,IAAM,kBAAN,cAA8B,MAAM;AAAA,EAMzC,YACE,MACA,SACA,SACA;AACA,UAAM,UAAU,GAAG,WAAW,WAAW,IAAI;AAC7C,UAAM,GAAG,OAAO;AAAA,MAAS,OAAO,IAAI,OAAO;AAC3C,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,UAAU;AAAA,EACjB;AACF;AAqBO,SAAS,kBAAkB,OAA0C;AAC1E,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AAEA,MAAI;AACF,UAAM,EAAE,MAAM,KAAK,IAAwC;AAE3D,WAAO,YAAY,IAAI,IAAI,KAAK,YAAY,IAAI,IAAI;AAAA,EACtD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,IAAM,aAAa;AAMnB,SAAS,cAAc,OAAwB;AACpD,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO,MAAM,KAAK;AAAA,EACpB;AAGA,MAAI,OAAO,UAAU,YAAY;AAC/B,WAAO;AAAA,EACT;AAGA,MAAI,UAAU,QAAQ,OAAO,UAAU,UAAU;AAC/C,WAAO,MAAM,OAAO,KAAK,CAAC,EAAE,MAAM,GAAG,EAAE;AAAA,EACzC;AAEA,SAAO,MAAM,QAAQ,KAAK,IAAI,aAAa;AAC7C;AAIA,IAAM,oBACJ;AAMK,SAAS,MAAM,MAAsB;AAC1C,SAAO,KAAK,UAAU,SAAS,IAAI,CAAC,EAAE;AAAA,IACpC;AAAA,IACA,CAAC,SAAS,MAAM,KAAK,WAAW,CAAC,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC;AAAA,EAClE;AACF;AAEO,SAAS,SAAS,MAAsB;AAC7C,MAAI,KAAK,UAAU,YAAY;AAC7B,WAAO;AAAA,EACT;AAEA,MAAI,MAAM,aAAa;AACvB,QAAM,OAAO,KAAK,WAAW,MAAM,CAAC;AACpC,MAAI,QAAQ,SAAU,QAAQ,OAAQ;AACpC;AAAA,EACF;AACA,SAAO,GAAG,KAAK,MAAM,GAAG,GAAG,CAAC;AAC9B;","names":[]}
@@ -0,0 +1,36 @@
1
+ // src/format/duration.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ import { NBSP, pluralForm } from "./number.js";
4
+ import { readSettings } from "./options.js";
5
+ var SHORT_LABELS = {
6
+ hour: "hr",
7
+ minute: "min",
8
+ second: "sec"
9
+ };
10
+ function formatDuration(minutes, options) {
11
+ const fn = "formatDuration";
12
+ if (!Number.isFinite(minutes) || minutes < 0) {
13
+ throw new RecipeBookError(
14
+ "invalid_argument",
15
+ `${fn}: minutes must be a finite number, 0 or more, received ${describeValue(minutes)}.`
16
+ );
17
+ }
18
+ const { number, unitDisplay } = readSettings(fn, options);
19
+ const partDay = minutes % 1440;
20
+ const seconds = Math.round(partDay * 60);
21
+ const days = Math.round((minutes - partDay) / 1440) + (seconds === 86400 ? 1 : 0);
22
+ const counts = [
23
+ ["day", days],
24
+ ["hour", Math.floor(seconds / 3600) % 24],
25
+ ["minute", Math.floor(seconds / 60) % 60],
26
+ ["second", seconds % 60]
27
+ ];
28
+ const parts = counts.filter(([, count]) => count > 0);
29
+ return (parts.length > 0 ? parts : [["minute", 0]]).map(
30
+ ([unit, count]) => `${number(count, 0)}${NBSP}${unitDisplay === "short" && SHORT_LABELS[unit] || (pluralForm(count) === "one" ? unit : `${unit}s`)}`
31
+ ).join(" ");
32
+ }
33
+ export {
34
+ formatDuration
35
+ };
36
+ //# sourceMappingURL=duration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/format/duration.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { NBSP, pluralForm } from './number'\nimport { type FormatOptions, readSettings } from './options'\n\ntype Unit = 'day' | 'hour' | 'minute' | 'second'\n\nconst SHORT_LABELS: Partial<Record<Unit, string>> = {\n hour: 'hr',\n minute: 'min',\n second: 'sec',\n}\n\n/**\n * Formats a duration in minutes, such as a recipe's `times.cook`: \"45 min\",\n * \"1 hr 15 min\", or with `unitDisplay: 'long'`, \"1 hour 15 minutes\".\n *\n * The duration is rounded to the second and written in days, hours, minutes,\n * and seconds, leaving out zero parts. Each number and unit is joined by a\n * no-break space (U+00A0).\n *\n * @throws {RecipeBookError} `invalid_argument` when `minutes` is not a finite number, 0 or more, or for an invalid option.\n *\n * @example\n * formatDuration(75) // \"1 hr 15 min\"\n * formatDuration(1.5) // \"1 min 30 sec\"\n * formatDuration(2880, { unitDisplay: 'long' }) // \"2 days\"\n * formatDuration(0) // \"0 min\"\n */\nexport function formatDuration(\n minutes: number,\n options?: FormatOptions,\n): string {\n const fn = 'formatDuration'\n\n if (!Number.isFinite(minutes) || minutes < 0) {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: minutes must be a finite number, 0 or more, received ${describeValue(minutes)}.`,\n )\n }\n\n const { number, unitDisplay } = readSettings(fn, options)\n const partDay = minutes % 1440\n const seconds = Math.round(partDay * 60)\n const days =\n Math.round((minutes - partDay) / 1440) + (seconds === 86400 ? 1 : 0)\n const counts: Array<readonly [Unit, number]> = [\n ['day', days],\n ['hour', Math.floor(seconds / 3600) % 24],\n ['minute', Math.floor(seconds / 60) % 60],\n ['second', seconds % 60],\n ]\n\n const parts = counts.filter(([, count]) => count > 0)\n\n return (parts.length > 0 ? parts : ([['minute', 0]] as const))\n .map(\n ([unit, count]) =>\n `${number(count, 0)}${NBSP}${\n (unitDisplay === 'short' && SHORT_LABELS[unit]) ||\n (pluralForm(count) === 'one' ? unit : `${unit}s`)\n }`,\n )\n .join(' ')\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAC/C,SAAS,MAAM,kBAAkB;AACjC,SAA6B,oBAAoB;AAIjD,IAAM,eAA8C;AAAA,EAClD,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AACV;AAkBO,SAAS,eACd,SACA,SACQ;AACR,QAAM,KAAK;AAEX,MAAI,CAAC,OAAO,SAAS,OAAO,KAAK,UAAU,GAAG;AAC5C,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,0DAA0D,cAAc,OAAO,CAAC;AAAA,IACvF;AAAA,EACF;AAEA,QAAM,EAAE,QAAQ,YAAY,IAAI,aAAa,IAAI,OAAO;AACxD,QAAM,UAAU,UAAU;AAC1B,QAAM,UAAU,KAAK,MAAM,UAAU,EAAE;AACvC,QAAM,OACJ,KAAK,OAAO,UAAU,WAAW,IAAI,KAAK,YAAY,QAAQ,IAAI;AACpE,QAAM,SAAyC;AAAA,IAC7C,CAAC,OAAO,IAAI;AAAA,IACZ,CAAC,QAAQ,KAAK,MAAM,UAAU,IAAI,IAAI,EAAE;AAAA,IACxC,CAAC,UAAU,KAAK,MAAM,UAAU,EAAE,IAAI,EAAE;AAAA,IACxC,CAAC,UAAU,UAAU,EAAE;AAAA,EACzB;AAEA,QAAM,QAAQ,OAAO,OAAO,CAAC,CAAC,EAAE,KAAK,MAAM,QAAQ,CAAC;AAEpD,UAAQ,MAAM,SAAS,IAAI,QAAS,CAAC,CAAC,UAAU,CAAC,CAAC,GAC/C;AAAA,IACC,CAAC,CAAC,MAAM,KAAK,MACX,GAAG,OAAO,OAAO,CAAC,CAAC,GAAG,IAAI,GACvB,gBAAgB,WAAW,aAAa,IAAI,MAC5C,WAAW,KAAK,MAAM,QAAQ,OAAO,GAAG,IAAI,IAC/C;AAAA,EACJ,EACC,KAAK,GAAG;AACb;","names":[]}
@@ -0,0 +1,49 @@
1
+ // src/format/ingredient.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ import { NBSP } from "./number.js";
4
+ import {
5
+ joinParts,
6
+ quantityParts,
7
+ readQuantitySettings
8
+ } from "./quantity.js";
9
+ function formatIngredient(item, options) {
10
+ const fn = "formatIngredient";
11
+ const ingredient = item?.ingredient;
12
+ if (typeof ingredient?.name !== "string") {
13
+ throw new RecipeBookError(
14
+ "invalid_argument",
15
+ `${fn}: expected { ingredient, quantity? }, such as a resolved ingredient, received ${describeValue(item)}.`
16
+ );
17
+ }
18
+ for (const key of ["plural", "note"]) {
19
+ const value = ingredient[key];
20
+ if (value !== void 0 && typeof value !== "string") {
21
+ throw new RecipeBookError(
22
+ "invalid_argument",
23
+ `${fn}: ingredient.${key} must be a string, received ${describeValue(value)}.`
24
+ );
25
+ }
26
+ }
27
+ const settings = readQuantitySettings(fn, options);
28
+ const quantity = item.quantity ?? ingredient.quantity;
29
+ const { note } = ingredient;
30
+ const noteText = note ? `, ${note}` : "";
31
+ if (!quantity) {
32
+ const { name: name2 } = ingredient;
33
+ return { name: name2, ...note && { note }, text: name2 + noteText };
34
+ }
35
+ const { parts, plural } = quantityParts(fn, quantity, settings);
36
+ const quantityText = joinParts(parts);
37
+ const isCount = quantity.unit === void 0;
38
+ const name = isCount && plural === "other" ? ingredient.plural ?? ingredient.name : ingredient.name;
39
+ return {
40
+ quantity: quantityText,
41
+ name,
42
+ ...note && { note },
43
+ text: `${quantityText}${isCount ? NBSP : " "}${name}${noteText}`
44
+ };
45
+ }
46
+ export {
47
+ formatIngredient
48
+ };
49
+ //# sourceMappingURL=ingredient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/format/ingredient.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport type { Quantity } from '../quantity/types'\nimport type { Ingredient } from '../recipe/types'\nimport { NBSP } from './number'\nimport {\n type FormatQuantityOptions,\n joinParts,\n quantityParts,\n readQuantitySettings,\n} from './quantity'\n\n/** An ingredient line as text, in pieces for styling and as one `text` line. */\nexport type FormattedIngredient = {\n /** The formatted quantity, as from `formatQuantity`. Absent for an unquantified line. */\n quantity?: string | undefined\n /** The name, or the ingredient's `plural` when a bare count takes the plural: \"2 eggs\", \"1–2 eggs\". */\n name: string\n /** The ingredient's note, such as \"sifted\". Absent when it has none. */\n note?: string | undefined\n /**\n * The whole line: `'1 ½ cups flour, sifted'`, `'2 eggs'`, `'salt, to taste'`.\n * It never says \"optional\"; mark optional ingredients in the UI.\n */\n text: string\n}\n\n/**\n * Formats an ingredient line. Accepts a resolved ingredient, or any\n * `{ ingredient, quantity? }`; without `quantity`, the ingredient's own\n * quantity is used.\n *\n * Takes the same options and rounding as `formatQuantity`. A bare count that\n * takes the plural, such as `2`, `1–2`, or `0.1`, uses the ingredient's\n * `plural`, or `name` when it has none. The name follows a no-break space\n * (U+00A0) after any bare count, `1 egg` included, and a regular space after\n * a unit.\n *\n * @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.\n *\n * @example\n * formatIngredient(resolved).text // \"250 g all-purpose flour\"\n * formatIngredient({ ingredient: eggs, quantity: { amount: 2 } }).text // \"2 eggs\"\n * formatIngredient({ ingredient: salt }) // { name: 'salt', note: 'to taste', text: 'salt, to taste' }\n */\nexport function formatIngredient(\n item: { ingredient: Ingredient; quantity?: Quantity | undefined },\n options?: FormatQuantityOptions,\n): FormattedIngredient {\n const fn = 'formatIngredient'\n const ingredient = item?.ingredient\n\n if (typeof ingredient?.name !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: expected { ingredient, quantity? }, such as a resolved ingredient, received ${describeValue(item)}.`,\n )\n }\n for (const key of ['plural', 'note'] as const) {\n const value: unknown = ingredient[key]\n if (value !== undefined && typeof value !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: ingredient.${key} must be a string, received ${describeValue(value)}.`,\n )\n }\n }\n\n const settings = readQuantitySettings(fn, options)\n const quantity = item.quantity ?? ingredient.quantity\n const { note } = ingredient\n const noteText = note ? `, ${note}` : ''\n\n if (!quantity) {\n const { name } = ingredient\n return { name, ...(note && { note }), text: name + noteText }\n }\n\n const { parts, plural } = quantityParts(fn, quantity, settings)\n const quantityText = joinParts(parts)\n const isCount = quantity.unit === undefined\n const name =\n isCount && plural === 'other'\n ? (ingredient.plural ?? ingredient.name)\n : ingredient.name\n\n return {\n quantity: quantityText,\n name,\n ...(note && { note }),\n text: `${quantityText}${isCount ? NBSP : ' '}${name}${noteText}`,\n }\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAG/C,SAAS,YAAY;AACrB;AAAA,EAEE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAmCA,SAAS,iBACd,MACA,SACqB;AACrB,QAAM,KAAK;AACX,QAAM,aAAa,MAAM;AAEzB,MAAI,OAAO,YAAY,SAAS,UAAU;AACxC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,iFAAiF,cAAc,IAAI,CAAC;AAAA,IAC3G;AAAA,EACF;AACA,aAAW,OAAO,CAAC,UAAU,MAAM,GAAY;AAC7C,UAAM,QAAiB,WAAW,GAAG;AACrC,QAAI,UAAU,UAAa,OAAO,UAAU,UAAU;AACpD,YAAM,IAAI;AAAA,QACR;AAAA,QACA,GAAG,EAAE,gBAAgB,GAAG,+BAA+B,cAAc,KAAK,CAAC;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AAEA,QAAM,WAAW,qBAAqB,IAAI,OAAO;AACjD,QAAM,WAAW,KAAK,YAAY,WAAW;AAC7C,QAAM,EAAE,KAAK,IAAI;AACjB,QAAM,WAAW,OAAO,KAAK,IAAI,KAAK;AAEtC,MAAI,CAAC,UAAU;AACb,UAAM,EAAE,MAAAA,MAAK,IAAI;AACjB,WAAO,EAAE,MAAAA,OAAM,GAAI,QAAQ,EAAE,KAAK,GAAI,MAAMA,QAAO,SAAS;AAAA,EAC9D;AAEA,QAAM,EAAE,OAAO,OAAO,IAAI,cAAc,IAAI,UAAU,QAAQ;AAC9D,QAAM,eAAe,UAAU,KAAK;AACpC,QAAM,UAAU,SAAS,SAAS;AAClC,QAAM,OACJ,WAAW,WAAW,UACjB,WAAW,UAAU,WAAW,OACjC,WAAW;AAEjB,SAAO;AAAA,IACL,UAAU;AAAA,IACV;AAAA,IACA,GAAI,QAAQ,EAAE,KAAK;AAAA,IACnB,MAAM,GAAG,YAAY,GAAG,UAAU,OAAO,GAAG,GAAG,IAAI,GAAG,QAAQ;AAAA,EAChE;AACF;","names":["name"]}
@@ -0,0 +1,120 @@
1
+ // src/format/number.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ var POWERS = [
4
+ 1,
5
+ 10,
6
+ 100,
7
+ 1e3,
8
+ 1e4,
9
+ 1e5,
10
+ 1e6,
11
+ 1e7,
12
+ 1e8,
13
+ 1e9,
14
+ 1e10,
15
+ 1e11,
16
+ 1e12,
17
+ 1e13,
18
+ 1e14,
19
+ 1e15
20
+ ];
21
+ var MAX_DIGITS = 15;
22
+ var NBSP = "\xA0";
23
+ function round(value, digits) {
24
+ const power = POWERS[digits];
25
+ const magnitude = Math.abs(value);
26
+ const scaled = magnitude * power;
27
+ const rounded = scaled < 1e15 ? Math.round(Number(scaled.toPrecision(15))) / power : Math.round(magnitude);
28
+ return value < 0 && rounded !== 0 ? -rounded : rounded;
29
+ }
30
+ function roundAboveZero(value, digits) {
31
+ for (let used = digits; used <= MAX_DIGITS; used++) {
32
+ const rounded = round(value, used);
33
+ if (rounded > 0) {
34
+ return [rounded, used];
35
+ }
36
+ }
37
+ return [1e-15, MAX_DIGITS];
38
+ }
39
+ var writers = /* @__PURE__ */ new Map();
40
+ var MAX_WRITERS = 32;
41
+ function numberWriter(fn, locale = "en-US") {
42
+ if (typeof locale !== "string") {
43
+ throw invalidLocale(fn, locale);
44
+ }
45
+ if (/^en(?:-(?:[a-z]{2}|\d{3}))?$/i.test(locale) || typeof Intl === "undefined") {
46
+ return englishNumber;
47
+ }
48
+ let writer = writers.get(locale);
49
+ if (!writer) {
50
+ writer = intlNumber(fn, locale);
51
+ if (writer !== englishNumber) {
52
+ if (writers.size >= MAX_WRITERS) {
53
+ writers.clear();
54
+ }
55
+ writers.set(locale, writer);
56
+ }
57
+ }
58
+ return writer;
59
+ }
60
+ function pluralForm(value) {
61
+ return Math.abs(value) === 1 ? "one" : "other";
62
+ }
63
+ function intlNumber(fn, locale) {
64
+ let supported;
65
+ try {
66
+ supported = Intl.NumberFormat.supportedLocalesOf(locale);
67
+ } catch {
68
+ throw invalidLocale(fn, locale);
69
+ }
70
+ if (supported.length === 0) {
71
+ return englishNumber;
72
+ }
73
+ const formats = [];
74
+ return (value, digits) => {
75
+ let format = formats[digits];
76
+ if (!format) {
77
+ format = new Intl.NumberFormat(locale, {
78
+ useGrouping: false,
79
+ maximumFractionDigits: digits
80
+ });
81
+ formats[digits] = format;
82
+ }
83
+ return format.format(value);
84
+ };
85
+ }
86
+ function englishNumber(value, digits) {
87
+ const magnitude = Math.abs(value);
88
+ const units = Math.round(magnitude * POWERS[digits]);
89
+ const sign = value < 0 ? "-" : "";
90
+ if (digits === 0 || units > Number.MAX_SAFE_INTEGER) {
91
+ return sign + integerText(magnitude);
92
+ }
93
+ const text = String(units).padStart(digits + 1, "0");
94
+ const whole = text.slice(0, -digits);
95
+ const fraction = text.slice(-digits).replace(/0+$/, "");
96
+ return sign + (fraction ? `${whole}.${fraction}` : whole);
97
+ }
98
+ function integerText(value) {
99
+ const text = String(value);
100
+ const match = /^(\d)(?:\.(\d+))?e\+(\d+)$/.exec(text);
101
+ if (!match) {
102
+ return text;
103
+ }
104
+ const [, head, tail = "", exponent] = match;
105
+ return head + tail.padEnd(Number(exponent), "0");
106
+ }
107
+ function invalidLocale(fn, locale) {
108
+ return new RecipeBookError(
109
+ "invalid_argument",
110
+ `${fn}: locale must be a language tag that Intl accepts, such as "en-US", received ${describeValue(locale)}.`
111
+ );
112
+ }
113
+ export {
114
+ NBSP,
115
+ numberWriter,
116
+ pluralForm,
117
+ round,
118
+ roundAboveZero
119
+ };
120
+ //# sourceMappingURL=number.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/format/number.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\n\n// Literals, not `10 ** n`: Math.pow is implementation-approximated, so engines may disagree.\nconst POWERS = [\n 1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14,\n 1e15,\n] as const\nconst MAX_DIGITS = 15\n\n/** Joins a number to its unit so the two never wrap apart. */\nexport const NBSP = '\\u00A0'\n\nexport type PluralForm = 'one' | 'other'\n\n/** Writes a value already rounded to `digits` fraction digits, without trailing zeros or grouping. */\nexport type NumberWriter = (value: number, digits: number) => string\n\n/**\n * Rounds half away from zero to `digits` fraction digits (0 to 15), at 15\n * significant digits so 1.005 rounds to 1.01 although its double is just\n * below. When `digits` would need more than 15 significant digits, rounds to a\n * whole number. Never returns -0.\n */\nexport function round(value: number, digits: number): number {\n const power = POWERS[digits]!\n const magnitude = Math.abs(value)\n const scaled = magnitude * power\n const rounded =\n scaled < 1e15\n ? Math.round(Number(scaled.toPrecision(15))) / power\n : Math.round(magnitude)\n\n return value < 0 && rounded !== 0 ? -rounded : rounded\n}\n\n/**\n * Rounds a positive value to at least `digits` fraction digits, adding digits\n * until the result is not 0, so a small amount never reads as 0.\n */\nexport function roundAboveZero(\n value: number,\n digits: number,\n): [rounded: number, digits: number] {\n for (let used = digits; used <= MAX_DIGITS; used++) {\n const rounded = round(value, used)\n if (rounded > 0) {\n return [rounded, used]\n }\n }\n\n return [1e-15, MAX_DIGITS]\n}\n\n// Keyed by caller input, which may come from a request, so the cache is bounded.\nconst writers = new Map<string, NumberWriter>()\nconst MAX_WRITERS = 32\n\n/**\n * The number writer for `locale`. English (`en` or `en-` and a region) is\n * hand-written, so the default output never depends on the engine's ICU data.\n * Other tags, including English ones with extensions such as\n * `en-US-u-nu-arab`, use `Intl` with the explicit locale; unsupported ones\n * fall back to English rather than to the runtime's default locale.\n *\n * @throws {RecipeBookError} `invalid_argument` when `locale` isn't a string or\n * is one `Intl` rejects, such as `en_US`. `Intl` also rejects some BCP 47\n * tags, such as `i-klingon` and `x-private`. Without `Intl`, only a `locale`\n * that isn't a string throws, and every string writes English.\n */\nexport function numberWriter(\n fn: string,\n locale: unknown = 'en-US',\n): NumberWriter {\n if (typeof locale !== 'string') {\n throw invalidLocale(fn, locale)\n }\n\n if (\n /^en(?:-(?:[a-z]{2}|\\d{3}))?$/i.test(locale) ||\n typeof Intl === 'undefined'\n ) {\n return englishNumber\n }\n\n let writer = writers.get(locale)\n if (!writer) {\n writer = intlNumber(fn, locale)\n if (writer !== englishNumber) {\n if (writers.size >= MAX_WRITERS) {\n writers.clear()\n }\n writers.set(locale, writer)\n }\n }\n\n return writer\n}\n\n/**\n * The English plural form of a number written in digits, which every label\n * uses until labels are localized: `one` for 1, `other` for anything else,\n * including 0 and decimals (\"0.5 cups\").\n */\nexport function pluralForm(value: number): PluralForm {\n return Math.abs(value) === 1 ? 'one' : 'other'\n}\n\nfunction intlNumber(fn: string, locale: string): NumberWriter {\n let supported: readonly string[]\n try {\n supported = Intl.NumberFormat.supportedLocalesOf(locale)\n } catch {\n throw invalidLocale(fn, locale)\n }\n\n if (supported.length === 0) {\n return englishNumber\n }\n\n const formats: Intl.NumberFormat[] = []\n return (value, digits) => {\n let format = formats[digits]\n if (!format) {\n format = new Intl.NumberFormat(locale, {\n useGrouping: false,\n maximumFractionDigits: digits,\n })\n formats[digits] = format\n }\n\n return format.format(value)\n }\n}\n\nfunction englishNumber(value: number, digits: number): string {\n const magnitude = Math.abs(value)\n const units = Math.round(magnitude * POWERS[digits]!)\n const sign = value < 0 ? '-' : ''\n\n if (digits === 0 || units > Number.MAX_SAFE_INTEGER) {\n return sign + integerText(magnitude)\n }\n\n const text = String(units).padStart(digits + 1, '0')\n const whole = text.slice(0, -digits)\n const fraction = text.slice(-digits).replace(/0+$/, '')\n\n return sign + (fraction ? `${whole}.${fraction}` : whole)\n}\n\n/** Writes a non-negative integer in plain digits; `String` switches to exponent notation from 1e21. */\nfunction integerText(value: number): string {\n const text = String(value)\n const match = /^(\\d)(?:\\.(\\d+))?e\\+(\\d+)$/.exec(text)\n\n if (!match) {\n return text\n }\n\n const [, head, tail = '', exponent] = match\n return head + tail.padEnd(Number(exponent), '0')\n}\n\nfunction invalidLocale(fn: string, locale: unknown): RecipeBookError {\n return new RecipeBookError(\n 'invalid_argument',\n `${fn}: locale must be a language tag that Intl accepts, such as \"en-US\", received ${describeValue(locale)}.`,\n )\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,31 @@
1
+ // src/format/options.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ import { numberWriter } from "./number.js";
4
+ function readSettings(fn, options) {
5
+ return {
6
+ number: numberWriter(fn, options?.locale),
7
+ unitDisplay: option(fn, "unitDisplay", options?.unitDisplay, [
8
+ "short",
9
+ "long"
10
+ ])
11
+ };
12
+ }
13
+ function option(fn, name, value, allowed) {
14
+ if (value === void 0) {
15
+ return allowed[0];
16
+ }
17
+ if (allowed.includes(value)) {
18
+ return value;
19
+ }
20
+ const choices = allowed.map((choice) => JSON.stringify(choice));
21
+ const last = choices.pop();
22
+ throw new RecipeBookError(
23
+ "invalid_argument",
24
+ `${fn}: ${name} must be ${choices.join(", ")}${choices.length > 1 ? "," : ""} or ${last}, received ${describeValue(value)}.`
25
+ );
26
+ }
27
+ export {
28
+ option,
29
+ readSettings
30
+ };
31
+ //# sourceMappingURL=options.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/format/options.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { type NumberWriter, numberWriter } from './number'\n\n/**\n * Options every `format*` function takes. Every default is a fixed value,\n * never read from the runtime, so server and client render the same text.\n */\nexport type FormatOptions = {\n /**\n * A language tag that `Intl` accepts, such as `'en-US'`, the default. Labels\n * and plurals are English in 0.1; only numbers follow the locale, and they\n * are never grouped. Numbers in other languages come from the runtime's\n * `Intl` data, so the server and client need the same locale support to\n * render the same text. A locale the runtime doesn't support falls back to\n * English, but a string `Intl` rejects throws. `Intl` rejects `'en_US'`, and\n * some BCP 47 tags too, such as `'i-klingon'` and `'x-private'`.\n */\n locale?: string | undefined\n /** `'short'` (`tbsp`) or `'long'` (`tablespoons`). Default `'short'`. */\n unitDisplay?: 'short' | 'long' | undefined\n}\n\nexport type Settings = {\n number: NumberWriter\n unitDisplay: 'short' | 'long'\n}\n\nexport function readSettings(\n fn: string,\n options: FormatOptions | undefined,\n): Settings {\n return {\n number: numberWriter(fn, options?.locale),\n unitDisplay: option(fn, 'unitDisplay', options?.unitDisplay, [\n 'short',\n 'long',\n ]),\n }\n}\n\n/**\n * Reads an enumerated option; the first allowed value is the default.\n *\n * @throws {RecipeBookError} `invalid_argument` for any other value.\n */\nexport function option<const T extends string | boolean>(\n fn: string,\n name: string,\n value: unknown,\n allowed: readonly [T, ...T[]],\n): T {\n if (value === undefined) {\n return allowed[0]\n }\n\n if ((allowed as readonly unknown[]).includes(value)) {\n return value as T\n }\n\n const choices = allowed.map((choice) => JSON.stringify(choice))\n const last = choices.pop()\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: ${name} must be ${choices.join(', ')}${choices.length > 1 ? ',' : ''} or ${last}, received ${describeValue(value)}.`,\n )\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAC/C,SAA4B,oBAAoB;AA0BzC,SAAS,aACd,IACA,SACU;AACV,SAAO;AAAA,IACL,QAAQ,aAAa,IAAI,SAAS,MAAM;AAAA,IACxC,aAAa,OAAO,IAAI,eAAe,SAAS,aAAa;AAAA,MAC3D;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAOO,SAAS,OACd,IACA,MACA,OACA,SACG;AACH,MAAI,UAAU,QAAW;AACvB,WAAO,QAAQ,CAAC;AAAA,EAClB;AAEA,MAAK,QAA+B,SAAS,KAAK,GAAG;AACnD,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,QAAQ,IAAI,CAAC,WAAW,KAAK,UAAU,MAAM,CAAC;AAC9D,QAAM,OAAO,QAAQ,IAAI;AACzB,QAAM,IAAI;AAAA,IACR;AAAA,IACA,GAAG,EAAE,KAAK,IAAI,YAAY,QAAQ,KAAK,IAAI,CAAC,GAAG,QAAQ,SAAS,IAAI,MAAM,EAAE,OAAO,IAAI,cAAc,cAAc,KAAK,CAAC;AAAA,EAC3H;AACF;","names":[]}
@@ -0,0 +1,144 @@
1
+ // src/format/quantity.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ import { amountEnds, checkQuantity } from "../quantity/amount.js";
4
+ import { lookupUnit } from "../units/registry.js";
5
+ import { NBSP, pluralForm, roundAboveZero } from "./number.js";
6
+ import {
7
+ option,
8
+ readSettings
9
+ } from "./options.js";
10
+ var EIGHTHS_AND_THIRDS = [
11
+ [1, 8, "\u215B"],
12
+ [1, 4, "\xBC"],
13
+ [1, 3, "\u2153"],
14
+ [3, 8, "\u215C"],
15
+ [1, 2, "\xBD"],
16
+ [5, 8, "\u215D"],
17
+ [2, 3, "\u2154"],
18
+ [3, 4, "\xBE"],
19
+ [7, 8, "\u215E"],
20
+ [1, 1, ""]
21
+ ];
22
+ var QUARTERS = [
23
+ [1, 4, "\xBC"],
24
+ [1, 2, "\xBD"],
25
+ [3, 4, "\xBE"],
26
+ [1, 1, ""]
27
+ ];
28
+ function formatQuantity(quantity, options) {
29
+ const fn = "formatQuantity";
30
+ return joinParts(
31
+ quantityParts(fn, quantity, readQuantitySettings(fn, options)).parts
32
+ );
33
+ }
34
+ function formatQuantityToParts(quantity, options) {
35
+ const fn = "formatQuantityToParts";
36
+ return quantityParts(fn, quantity, readQuantitySettings(fn, options)).parts;
37
+ }
38
+ function readQuantitySettings(fn, options) {
39
+ return {
40
+ ...readSettings(fn, options),
41
+ fractions: option(fn, "fractions", options?.fractions, [true, false]),
42
+ rounding: option(fn, "rounding", options?.rounding, ["kitchen", "none"])
43
+ };
44
+ }
45
+ function quantityParts(fn, quantity, settings) {
46
+ const amount = checkQuantity(fn, quantity);
47
+ const unit = quantity.unit === void 0 ? void 0 : formatUnit(fn, quantity.unit);
48
+ const [low, high = low] = amountEnds(amount).map(
49
+ (value) => showAmount(value, settings, unit && unitSteps(unit, value))
50
+ );
51
+ const isRange = high.text !== low.text;
52
+ const plural = isRange ? "other" : low.plural;
53
+ const parts = [{ type: "amount", value: low.text }];
54
+ if (isRange) {
55
+ parts.push(
56
+ { type: "rangeSeparator", value: "\u2013" },
57
+ { type: "amount", value: high.text }
58
+ );
59
+ }
60
+ if (unit) {
61
+ parts.push(
62
+ { type: "literal", value: NBSP },
63
+ { type: "unit", value: unit.labels[settings.unitDisplay][plural] }
64
+ );
65
+ }
66
+ return { parts, plural };
67
+ }
68
+ function formatUnit(fn, id) {
69
+ const unit = lookupUnit(id);
70
+ if (!unit) {
71
+ throw new RecipeBookError(
72
+ "unknown_unit",
73
+ `${fn}: unit must be a unit id such as "g", "ml", or "cup", received ${describeValue(id)}. findUnit converts author text to ids; parseQuantity also suggests a fix for a misspelling.`
74
+ );
75
+ }
76
+ return unit;
77
+ }
78
+ function joinParts(parts) {
79
+ return parts.map((part) => part.value).join("");
80
+ }
81
+ function showAmount(value, settings, steps = QUARTERS) {
82
+ const { rounding, fractions } = settings;
83
+ if (rounding === "none") {
84
+ return decimal(value, 3, settings);
85
+ }
86
+ if (typeof steps === "number") {
87
+ return decimal(value, steps, settings);
88
+ }
89
+ const [numerator, denominator] = steps[0];
90
+ if (value < numerator / denominator * (1 - 1e-9)) {
91
+ return decimal(value, 1, settings);
92
+ }
93
+ const [whole, part, glyph] = snap(value, steps);
94
+ if (!fractions) {
95
+ return decimal(whole + part, 2, settings);
96
+ }
97
+ const wholeText = settings.number(whole, 0);
98
+ const text = glyph === "" ? wholeText : whole === 0 ? glyph : `${wholeText}${NBSP}${glyph}`;
99
+ return { text, plural: whole + part <= 1 ? "one" : "other" };
100
+ }
101
+ function unitSteps(unit, value) {
102
+ if (unit.dimension === "count") {
103
+ return QUARTERS;
104
+ }
105
+ if (unit.systems.includes("us")) {
106
+ return EIGHTHS_AND_THIRDS;
107
+ }
108
+ switch (unit.id) {
109
+ case "mg":
110
+ return 0;
111
+ case "kg":
112
+ case "l":
113
+ return 2;
114
+ default:
115
+ return value < 10 ? 1 : 0;
116
+ }
117
+ }
118
+ function snap(value, steps) {
119
+ const floor = Math.floor(value);
120
+ const part = value - floor;
121
+ let nearest = 0;
122
+ let nearestGlyph = "";
123
+ for (const [numerator, denominator, glyph] of steps) {
124
+ const step = numerator / denominator;
125
+ if (Math.abs(part - step) <= Math.abs(part - nearest) + 1e-9) {
126
+ nearest = step;
127
+ nearestGlyph = glyph;
128
+ }
129
+ }
130
+ return nearest === 1 ? [floor + 1, 0, ""] : [floor, nearest, nearestGlyph];
131
+ }
132
+ function decimal(value, digits, settings) {
133
+ const [rounded, used] = roundAboveZero(value, digits);
134
+ return { text: settings.number(rounded, used), plural: pluralForm(rounded) };
135
+ }
136
+ export {
137
+ formatQuantity,
138
+ formatQuantityToParts,
139
+ joinParts,
140
+ quantityParts,
141
+ readQuantitySettings,
142
+ showAmount
143
+ };
144
+ //# sourceMappingURL=quantity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/format/quantity.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { amountEnds, checkQuantity } from '../quantity/amount'\nimport type { Quantity } from '../quantity/types'\nimport { lookupUnit, type UnitDefinition } from '../units/registry'\nimport { NBSP, type PluralForm, pluralForm, roundAboveZero } from './number'\nimport {\n type FormatOptions,\n option,\n readSettings,\n type Settings,\n} from './options'\n\n/**\n * Options for {@link formatQuantity}, {@link formatQuantityToParts},\n * `formatIngredient`, `formatServings`, and `formatYield`.\n */\nexport type FormatQuantityOptions = FormatOptions & {\n /**\n * Write US units and counts as fractions, such as `1 ½`. Default `true`.\n * `false` writes up to two decimals instead (`1.5 cups`, `0.33 cups`), or\n * more for an amount that would otherwise read as 0. Metric units always\n * use decimals.\n */\n fractions?: boolean | undefined\n /**\n * `'kitchen'` (default) rounds to amounts you can measure: US units to the\n * nearest ⅛ or ⅓, counts to the nearest ¼, metric units to sensible\n * decimals. `'none'` writes up to three decimals, or more for an amount\n * that would otherwise read as 0.\n */\n rounding?: 'kitchen' | 'none' | undefined\n}\n\n/**\n * One piece of a formatted quantity, in the style of `Intl` `formatToParts`.\n * Joining every `value` gives the {@link formatQuantity} text. Unlike `Intl`,\n * a range's dash is its own `rangeSeparator` part.\n */\nexport type QuantityPart = {\n /** May gain members in a minor release, so give an exhaustive `switch` a default case. */\n type: 'amount' | 'rangeSeparator' | 'unit' | 'literal'\n value: string\n}\n\n// Integer pairs rather than `1 / 8`: bundlers keep tables with arithmetic in every bundle.\ntype Step = readonly [numerator: number, denominator: number, glyph: string]\ntype Steps = readonly [Step, ...Step[]]\n\nconst EIGHTHS_AND_THIRDS: Steps = [\n [1, 8, '⅛'],\n [1, 4, '¼'],\n [1, 3, '⅓'],\n [3, 8, '⅜'],\n [1, 2, '½'],\n [5, 8, '⅝'],\n [2, 3, '⅔'],\n [3, 4, '¾'],\n [7, 8, '⅞'],\n [1, 1, ''],\n]\n\nconst QUARTERS: Steps = [\n [1, 4, '¼'],\n [1, 2, '½'],\n [3, 4, '¾'],\n [1, 1, ''],\n]\n\nexport type QuantitySettings = Settings & {\n fractions: boolean\n rounding: 'kitchen' | 'none'\n}\n\nexport type Shown = { text: string; plural: PluralForm }\n\n/**\n * Formats a quantity for display, with kitchen rounding by default.\n *\n * - US units (tsp, tbsp, cup, fl oz, pint, quart, gallon, oz, lb) round to the\n * nearest ⅛ or ⅓ and counts to the nearest ¼, written as fractions. An\n * amount below the smallest step is written as a decimal instead, never as 0.\n * - g, mL, cL, and dL use one decimal below 10 and none from 10; kg and L up\n * to two decimals; mg none. Small amounts gain digits rather than show 0.\n * - Ranges use an en dash (\"2–3 tbsp\"), and the amount and unit are joined by a\n * no-break space (U+00A0).\n * - The unit is singular for 1 and for a fraction below 1 (\"1 cup\", \"½ cup\"),\n * and plural otherwise, including decimals and ranges (\"0.5 cups\",\n * \"1 ½ cups\", \"½–1 cups\").\n *\n * For `en` alone or with a region, such as `'en-GB'`, output depends only on\n * the arguments: the locale defaults to `'en-US'`, and those numbers never go\n * through `Intl`. Other tags, including English ones with extensions such as\n * `'en-US-u-nu-arab'`, read the runtime's `Intl` data.\n *\n * @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.\n *\n * @example\n * formatQuantity({ amount: 1.5, unit: 'cup' }) // \"1 ½ cups\"\n * formatQuantity({ amount: { min: 2, max: 3 }, unit: 'tbsp' }) // \"2–3 tbsp\"\n * formatQuantity({ amount: 118.29, unit: 'ml' }) // \"118 mL\"\n * formatQuantity({ amount: 0.5, unit: 'tsp' }, { unitDisplay: 'long' }) // \"½ teaspoon\"\n */\nexport function formatQuantity(\n quantity: Quantity,\n options?: FormatQuantityOptions,\n): string {\n const fn = 'formatQuantity'\n return joinParts(\n quantityParts(fn, quantity, readQuantitySettings(fn, options)).parts,\n )\n}\n\n/**\n * Formats a quantity as parts, so a UI can style the amount and unit\n * separately without parsing the string.\n *\n * @throws {RecipeBookError} The same errors as {@link formatQuantity}, including `invalid_argument` for a quantity with `max` set.\n *\n * @example\n * formatQuantityToParts({ amount: { min: 2, max: 3 }, unit: 'tbsp' })\n * // [{ type: 'amount', value: '2' }, { type: 'rangeSeparator', value: '–' },\n * // { type: 'amount', value: '3' }, { type: 'literal', value: '\\u00A0' },\n * // { type: 'unit', value: 'tbsp' }]\n */\nexport function formatQuantityToParts(\n quantity: Quantity,\n options?: FormatQuantityOptions,\n): QuantityPart[] {\n const fn = 'formatQuantityToParts'\n return quantityParts(fn, quantity, readQuantitySettings(fn, options)).parts\n}\n\nexport function readQuantitySettings(\n fn: string,\n options: FormatQuantityOptions | undefined,\n): QuantitySettings {\n return {\n ...readSettings(fn, options),\n fractions: option(fn, 'fractions', options?.fractions, [true, false]),\n rounding: option(fn, 'rounding', options?.rounding, ['kitchen', 'none']),\n }\n}\n\nexport function quantityParts(\n fn: string,\n quantity: Quantity,\n settings: QuantitySettings,\n): { parts: QuantityPart[]; plural: PluralForm } {\n const amount = checkQuantity(fn, quantity)\n const unit =\n quantity.unit === undefined ? undefined : formatUnit(fn, quantity.unit)\n\n const [low, high = low] = amountEnds(amount).map((value) =>\n showAmount(value, settings, unit && unitSteps(unit, value)),\n ) as [Shown, Shown?]\n const isRange = high.text !== low.text\n const plural = isRange ? 'other' : low.plural\n\n const parts: QuantityPart[] = [{ type: 'amount', value: low.text }]\n if (isRange) {\n parts.push(\n { type: 'rangeSeparator', value: '–' },\n { type: 'amount', value: high.text },\n )\n }\n if (unit) {\n parts.push(\n { type: 'literal', value: NBSP },\n { type: 'unit', value: unit.labels[settings.unitDisplay][plural] },\n )\n }\n\n return { parts, plural }\n}\n\n/**\n * The registry entry for a quantity's unit. Unlike `getUnit`, the error\n * suggests no id: the alias index and edit distance behind that hint would\n * ship in every bundle that formats, though `parseRecipe` has already checked\n * the units of a parsed recipe.\n */\nfunction formatUnit(fn: string, id: unknown): UnitDefinition {\n const unit = lookupUnit(id)\n if (!unit) {\n throw new RecipeBookError(\n 'unknown_unit',\n `${fn}: unit must be a unit id such as \"g\", \"ml\", or \"cup\", received ${describeValue(id)}. findUnit converts author text to ids; parseQuantity also suggests a fix for a misspelling.`,\n )\n }\n return unit\n}\n\nexport function joinParts(parts: readonly QuantityPart[]): string {\n return parts.map((part) => part.value).join('')\n}\n\n/** One amount as text, snapped to `steps` or rounded to that many decimals; by default, as a bare count. Only callers with a unit pass `unitSteps`, so bundles that format counts alone leave out the unit tables. */\nexport function showAmount(\n value: number,\n settings: QuantitySettings,\n steps: Steps | number = QUARTERS,\n): Shown {\n const { rounding, fractions } = settings\n\n if (rounding === 'none') {\n return decimal(value, 3, settings)\n }\n\n if (typeof steps === 'number') {\n return decimal(value, steps, settings)\n }\n\n const [numerator, denominator] = steps[0]\n if (value < (numerator / denominator) * (1 - 1e-9)) {\n return decimal(value, 1, settings)\n }\n\n const [whole, part, glyph] = snap(value, steps)\n if (!fractions) {\n return decimal(whole + part, 2, settings)\n }\n\n const wholeText = settings.number(whole, 0)\n const text =\n glyph === ''\n ? wholeText\n : whole === 0\n ? glyph\n : `${wholeText}${NBSP}${glyph}`\n\n return { text, plural: whole + part <= 1 ? 'one' : 'other' }\n}\n\n/** The steps, or decimals, {@link showAmount} rounds an amount in `unit` to. */\nfunction unitSteps(unit: UnitDefinition, value: number): Steps | number {\n if (unit.dimension === 'count') {\n return QUARTERS\n }\n if (unit.systems.includes('us')) {\n return EIGHTHS_AND_THIRDS\n }\n\n switch (unit.id) {\n case 'mg':\n return 0\n case 'kg':\n case 'l':\n return 2\n default:\n return value < 10 ? 1 : 0\n }\n}\n\n/**\n * The nearest step to `value`'s fractional part; ties go to the larger step.\n * Thirds are inexact, so distances within 1e-9 count as ties.\n */\nfunction snap(\n value: number,\n steps: readonly Step[],\n): [whole: number, part: number, glyph: string] {\n const floor = Math.floor(value)\n const part = value - floor\n let nearest = 0\n let nearestGlyph = ''\n\n for (const [numerator, denominator, glyph] of steps) {\n const step = numerator / denominator\n if (Math.abs(part - step) <= Math.abs(part - nearest) + 1e-9) {\n nearest = step\n nearestGlyph = glyph\n }\n }\n\n return nearest === 1 ? [floor + 1, 0, ''] : [floor, nearest, nearestGlyph]\n}\n\nfunction decimal(\n value: number,\n digits: number,\n settings: QuantitySettings,\n): Shown {\n const [rounded, used] = roundAboveZero(value, digits)\n return { text: settings.number(rounded, used), plural: pluralForm(rounded) }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,68 @@
1
+ // src/format/servings.ts
2
+ import { describeValue, RecipeBookError } from "../errors.js";
3
+ import { isPositive } from "../quantity/amount.js";
4
+ import { NBSP } from "./number.js";
5
+ import {
6
+ readQuantitySettings,
7
+ showAmount
8
+ } from "./quantity.js";
9
+ function formatServings(servings, options) {
10
+ const fn = "formatServings";
11
+ return showRange(
12
+ fn,
13
+ servings,
14
+ "servings such as { amount: 4, max: 6 }",
15
+ readQuantitySettings(fn, options)
16
+ ).text;
17
+ }
18
+ function formatYield(yield_, options) {
19
+ const fn = "formatYield";
20
+ const { text, plural } = showRange(
21
+ fn,
22
+ yield_,
23
+ 'a yield such as { amount: 24, unit: "cookie" }',
24
+ readQuantitySettings(fn, options)
25
+ );
26
+ if (typeof yield_.unit !== "string") {
27
+ throw new RecipeBookError(
28
+ "invalid_argument",
29
+ `${fn}: unit must be a string such as "cookie", received ${describeValue(yield_.unit)}.`
30
+ );
31
+ }
32
+ if (yield_.plural !== void 0 && typeof yield_.plural !== "string") {
33
+ throw new RecipeBookError(
34
+ "invalid_argument",
35
+ `${fn}: plural must be a string, received ${describeValue(yield_.plural)}.`
36
+ );
37
+ }
38
+ return `${text}${NBSP}${plural === "other" ? yield_.plural ?? yield_.unit : yield_.unit}`;
39
+ }
40
+ function showRange(fn, value, expected, settings) {
41
+ if (typeof value !== "object" || value === null) {
42
+ throw new RecipeBookError(
43
+ "invalid_argument",
44
+ `${fn}: expected ${expected}, received ${describeValue(value)}.`
45
+ );
46
+ }
47
+ const { amount, max } = value;
48
+ if (!isPositive(amount)) {
49
+ throw new RecipeBookError(
50
+ "invalid_argument",
51
+ `${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`
52
+ );
53
+ }
54
+ if (max !== void 0 && !(isPositive(max) && max > amount)) {
55
+ throw new RecipeBookError(
56
+ "invalid_argument",
57
+ `${fn}: max must be a finite number greater than amount (${amount}), received ${describeValue(max)}. Leave max out for a single amount.`
58
+ );
59
+ }
60
+ const low = showAmount(amount, settings);
61
+ const high = max === void 0 ? low : showAmount(max, settings);
62
+ return high.text === low.text ? low : { text: `${low.text}\u2013${high.text}`, plural: "other" };
63
+ }
64
+ export {
65
+ formatServings,
66
+ formatYield
67
+ };
68
+ //# sourceMappingURL=servings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/format/servings.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { isPositive } from '../quantity/amount'\nimport type { Servings, Yield } from '../recipe/types'\nimport { NBSP } from './number'\nimport {\n type FormatQuantityOptions,\n type QuantitySettings,\n readQuantitySettings,\n type Shown,\n showAmount,\n} from './quantity'\n\n/**\n * Formats a number of servings as only the number or range: \"4\", \"4–6\",\n * \"2 ½\". The UI adds the word, as in \"Serves 4–6\" or \"4–6 servings\".\n *\n * Numbers format as `formatQuantity` formats a bare count. It takes\n * `formatQuantity`'s options; `unitDisplay` has no effect.\n *\n * Scale with `resolveRecipe`, and print with `formatServings` and `formatYield`, not `formatQuantity`.\n *\n * @throws {RecipeBookError} `invalid_argument` for a value that isn't `{ amount, max? }` with `max` above `amount`, or for an invalid option.\n *\n * @example\n * formatServings({ amount: 4 }) // \"4\"\n * formatServings({ amount: 4, max: 6 }) // \"4–6\"\n * formatServings({ amount: 2.5 }) // \"2 ½\": 5 servings at scale 0.5\n */\nexport function formatServings(\n servings: Servings,\n options?: FormatQuantityOptions,\n): string {\n const fn = 'formatServings'\n return showRange(\n fn,\n servings,\n 'servings such as { amount: 4, max: 6 }',\n readQuantitySettings(fn, options),\n ).text\n}\n\n/**\n * Formats what a batch makes: the number or range, a no-break space\n * (U+00A0), then the yield's word, as in \"24 cookies\", \"2–3 loaves\", or\n * \"1 loaf\".\n *\n * Numbers format as `formatQuantity` formats a bare count. The word is `unit`\n * for 1 and for a fraction below 1 (\"1 loaf\", \"½ loaf\"), and `plural`\n * otherwise, including decimals and ranges (\"1 ½ loaves\", \"1–2 loaves\"). A\n * yield read from text, such as `'2 loaves'`, has no `plural`, so its unit\n * prints as written. The word is the author's and is never translated. It\n * takes `formatQuantity`'s options; `unitDisplay` has no effect.\n *\n * Scale with `resolveRecipe`, and print with `formatServings` and `formatYield`, not `formatQuantity`.\n *\n * @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.\n *\n * @example\n * formatYield({ amount: 24, unit: 'cookie', plural: 'cookies' }) // \"24 cookies\"\n * formatYield({ amount: 1, unit: 'loaf', plural: 'loaves' }) // \"1 loaf\"\n * formatYield({ amount: 2, max: 3, unit: 'loaf', plural: 'loaves' }) // \"2–3 loaves\"\n * formatYield({ amount: 1.5, unit: 'loaf', plural: 'loaves' }) // \"1 ½ loaves\": 3 loaves at scale 0.5\n * formatYield({ amount: 2, unit: 'loaves' }) // \"2 loaves\"\n */\nexport function formatYield(\n yield_: Yield,\n options?: FormatQuantityOptions,\n): string {\n const fn = 'formatYield'\n const { text, plural } = showRange(\n fn,\n yield_,\n 'a yield such as { amount: 24, unit: \"cookie\" }',\n readQuantitySettings(fn, options),\n )\n\n if (typeof yield_.unit !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: unit must be a string such as \"cookie\", received ${describeValue(yield_.unit)}.`,\n )\n }\n if (yield_.plural !== undefined && typeof yield_.plural !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: plural must be a string, received ${describeValue(yield_.plural)}.`,\n )\n }\n\n return `${text}${NBSP}${plural === 'other' ? (yield_.plural ?? yield_.unit) : yield_.unit}`\n}\n\n/** A servings or yield amount, or its range, as `formatQuantity` writes a bare count. */\nfunction showRange(\n fn: string,\n value: { amount?: unknown; max?: unknown },\n expected: string,\n settings: QuantitySettings,\n): Shown {\n if (typeof value !== 'object' || value === null) {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: expected ${expected}, received ${describeValue(value)}.`,\n )\n }\n\n const { amount, max } = value\n if (!isPositive(amount)) {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`,\n )\n }\n if (max !== undefined && !(isPositive(max) && max > amount)) {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: max must be a finite number greater than amount (${amount}), received ${describeValue(max)}. Leave max out for a single amount.`,\n )\n }\n\n const low = showAmount(amount, settings)\n const high = max === undefined ? low : showAmount(max, settings)\n return high.text === low.text\n ? low\n : { text: `${low.text}–${high.text}`, plural: 'other' }\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAC/C,SAAS,kBAAkB;AAE3B,SAAS,YAAY;AACrB;AAAA,EAGE;AAAA,EAEA;AAAA,OACK;AAkBA,SAAS,eACd,UACA,SACQ;AACR,QAAM,KAAK;AACX,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,qBAAqB,IAAI,OAAO;AAAA,EAClC,EAAE;AACJ;AAyBO,SAAS,YACd,QACA,SACQ;AACR,QAAM,KAAK;AACX,QAAM,EAAE,MAAM,OAAO,IAAI;AAAA,IACvB;AAAA,IACA;AAAA,IACA;AAAA,IACA,qBAAqB,IAAI,OAAO;AAAA,EAClC;AAEA,MAAI,OAAO,OAAO,SAAS,UAAU;AACnC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,sDAAsD,cAAc,OAAO,IAAI,CAAC;AAAA,IACvF;AAAA,EACF;AACA,MAAI,OAAO,WAAW,UAAa,OAAO,OAAO,WAAW,UAAU;AACpE,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,uCAAuC,cAAc,OAAO,MAAM,CAAC;AAAA,IAC1E;AAAA,EACF;AAEA,SAAO,GAAG,IAAI,GAAG,IAAI,GAAG,WAAW,UAAW,OAAO,UAAU,OAAO,OAAQ,OAAO,IAAI;AAC3F;AAGA,SAAS,UACP,IACA,OACA,UACA,UACO;AACP,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,cAAc,QAAQ,cAAc,cAAc,KAAK,CAAC;AAAA,IAC/D;AAAA,EACF;AAEA,QAAM,EAAE,QAAQ,IAAI,IAAI;AACxB,MAAI,CAAC,WAAW,MAAM,GAAG;AACvB,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,6DAA6D,cAAc,MAAM,CAAC;AAAA,IACzF;AAAA,EACF;AACA,MAAI,QAAQ,UAAa,EAAE,WAAW,GAAG,KAAK,MAAM,SAAS;AAC3D,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,sDAAsD,MAAM,eAAe,cAAc,GAAG,CAAC;AAAA,IACpG;AAAA,EACF;AAEA,QAAM,MAAM,WAAW,QAAQ,QAAQ;AACvC,QAAM,OAAO,QAAQ,SAAY,MAAM,WAAW,KAAK,QAAQ;AAC/D,SAAO,KAAK,SAAS,IAAI,OACrB,MACA,EAAE,MAAM,GAAG,IAAI,IAAI,SAAI,KAAK,IAAI,IAAI,QAAQ,QAAQ;AAC1D;","names":[]}