@recipe-book/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{chunk-D5CV3UF7.cjs → chunk-ISRDJR2O.cjs} +42 -9
  3. package/dist/chunk-ISRDJR2O.cjs.map +1 -0
  4. package/dist/errors.js +29 -1
  5. package/dist/errors.js.map +1 -1
  6. package/dist/format/duration.js.map +1 -1
  7. package/dist/format/ingredient.js.map +1 -1
  8. package/dist/format/quantity.js.map +1 -1
  9. package/dist/format/servings.js.map +1 -1
  10. package/dist/format/temperature.js.map +1 -1
  11. package/dist/index.cjs +62 -58
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +38 -11
  14. package/dist/index.d.ts +38 -11
  15. package/dist/index.js +6 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/quantity/parse.js.map +1 -1
  18. package/dist/quantity/scale.js.map +1 -1
  19. package/dist/recipe/ids.js.map +1 -1
  20. package/dist/recipe/parse.js +11 -6
  21. package/dist/recipe/parse.js.map +1 -1
  22. package/dist/resolve.js.map +1 -1
  23. package/dist/schema-org/from-json-ld.js +16 -23
  24. package/dist/schema-org/from-json-ld.js.map +1 -1
  25. package/dist/schema-org/to-json-ld.js.map +1 -1
  26. package/dist/schema-org.cjs +75 -82
  27. package/dist/schema-org.cjs.map +1 -1
  28. package/dist/schema-org.d.cts +3 -2
  29. package/dist/schema-org.d.ts +3 -2
  30. package/dist/steps/segment.js.map +1 -1
  31. package/dist/steps/temperature.js +1 -1
  32. package/dist/steps/temperature.js.map +1 -1
  33. package/dist/{types-BknM8KOd.d.cts → types-CKIoWsz8.d.cts} +21 -4
  34. package/dist/{types-BknM8KOd.d.ts → types-CKIoWsz8.d.ts} +21 -4
  35. package/dist/units/convert.js.map +1 -1
  36. package/dist/units/temperature.js.map +1 -1
  37. package/dist/validation.js.map +1 -1
  38. package/package.json +1 -1
  39. package/dist/chunk-D5CV3UF7.cjs.map +0 -1
package/dist/errors.js CHANGED
@@ -15,9 +15,16 @@ var ERROR_NAMES = /* @__PURE__ */ new Set([
15
15
  ]);
16
16
  var RecipeBookError = class extends Error {
17
17
  constructor(code, message, options) {
18
- const docsUrl = `${DOCS_ORIGIN}/errors/${code}`;
18
+ const docsUrl = `${DOCS_ORIGIN}/errors/${code}/`;
19
19
  super(`${message}
20
20
  See ${docsUrl}`, options);
21
+ if ("cause" in Object(options)) {
22
+ Object.defineProperty(this, "cause", {
23
+ value: options?.cause,
24
+ writable: true,
25
+ configurable: true
26
+ });
27
+ }
21
28
  this.name = "RecipeBookError";
22
29
  this.code = code;
23
30
  this.docsUrl = docsUrl;
@@ -34,6 +41,26 @@ function isRecipeBookError(error) {
34
41
  return false;
35
42
  }
36
43
  }
44
+ var VALIDATION_CODES = /* @__PURE__ */ new Set([
45
+ "invalid_recipe",
46
+ "invalid_quantity",
47
+ "invalid_ingredient"
48
+ ]);
49
+ function isRecipeValidationError(error) {
50
+ if (typeof error !== "object" || error === null) {
51
+ return false;
52
+ }
53
+ try {
54
+ const {
55
+ name,
56
+ code,
57
+ issues
58
+ } = error;
59
+ return name === "RecipeValidationError" && VALIDATION_CODES.has(code) && Array.isArray(issues);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
37
64
  var MAX_ECHOED = 60;
38
65
  function describeValue(value) {
39
66
  if (typeof value === "string") {
@@ -70,6 +97,7 @@ export {
70
97
  RecipeBookError,
71
98
  describeValue,
72
99
  isRecipeBookError,
100
+ isRecipeValidationError,
73
101
  quote,
74
102
  truncate
75
103
  };
@@ -1 +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":[]}
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 // Chrome and Edge before 93, Firefox before 91, and Safari before 15 ignore\n // the built-in Error's options. Object() keeps a non-object `options` from\n // throwing, as the built-in Error ignores one. Defined rather than assigned,\n // so `cause` is non-enumerable there too, as the built-in Error makes it.\n if ('cause' in Object(options)) {\n Object.defineProperty(this, 'cause', {\n value: options?.cause,\n writable: true,\n configurable: true,\n })\n }\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\nconst VALIDATION_CODES: ReadonlySet<unknown> = new Set<ValidationErrorCode>([\n 'invalid_recipe',\n 'invalid_quantity',\n 'invalid_ingredient',\n])\n\n/**\n * True for a `RecipeValidationError` from any copy of Recipe Book, so its\n * `issues` can be read. Checks `name`, `code`, and `issues`, not `instanceof`.\n *\n * @example\n * if (isRecipeValidationError(error)) {\n * error.issues // readonly RecipeIssue[]\n * }\n */\nexport function isRecipeValidationError(\n error: unknown,\n): error is RecipeValidationError {\n if (typeof error !== 'object' || error === null) {\n return false\n }\n\n try {\n const {\n name,\n code,\n issues,\n }: { name?: unknown; code?: unknown; issues?: unknown } = error\n\n return (\n name === 'RecipeValidationError' &&\n VALIDATION_CODES.has(code) &&\n Array.isArray(issues)\n )\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;AAK3C,QAAI,WAAW,OAAO,OAAO,GAAG;AAC9B,aAAO,eAAe,MAAM,SAAS;AAAA,QACnC,OAAO,SAAS;AAAA,QAChB,UAAU;AAAA,QACV,cAAc;AAAA,MAChB,CAAC;AAAA,IACH;AACA,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;AAEA,IAAM,mBAAyC,oBAAI,IAAyB;AAAA,EAC1E;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAWM,SAAS,wBACd,OACgC;AAChC,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AAEA,MAAI;AACF,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,IACF,IAA0D;AAE1D,WACE,SAAS,2BACT,iBAAiB,IAAI,IAAI,KACzB,MAAM,QAAQ,MAAM;AAAA,EAExB,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":[]}
@@ -1 +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":[]}
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 * An error thrown by a getter or proxy in the options passes through.\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;AAoBO,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":[]}
@@ -1 +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"]}
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 * An error thrown by a getter or proxy in the input or options passes through.\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;AAqCA,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"]}
@@ -1 +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":[]}
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 * An error thrown by a getter or proxy in the input or options passes through.\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 * An error thrown by a getter or proxy in the input or options passes through.\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":[]}
@@ -1 +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":[]}
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 * An error thrown by a getter or proxy in the input or options passes through.\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 * An error thrown by a getter or proxy in the input or options passes through.\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;AAoBA,SAAS,eACd,UACA,SACQ;AACR,QAAM,KAAK;AACX,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,qBAAqB,IAAI,OAAO;AAAA,EAClC,EAAE;AACJ;AA2BO,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":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/format/temperature.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport type { Temperature } from '../units/temperature'\nimport { NBSP, pluralForm, round } from './number'\nimport { type FormatOptions, option, readSettings } from './options'\n\nconst OVEN_STEPS = { C: 10, F: 25 } as const\n\n/** Options for {@link formatTemperature}. */\nexport type FormatTemperatureOptions = FormatOptions & {\n /**\n * `'kitchen'` (default) writes whole degrees. `'oven'` rounds to the nearest\n * 10 °C or 25 °F. `'none'` writes up to three decimals.\n */\n rounding?: 'kitchen' | 'oven' | 'none' | undefined\n}\n\n/**\n * Formats a temperature, such as \"180 °C\" or \"350 degrees Fahrenheit\". The\n * number and unit are joined by a no-break space (U+00A0).\n *\n * Rounding is `'kitchen'` by default: whole degrees. `'oven'` rounds to the\n * steps oven dials and recipes use, the nearest 10 °C or 25 °F, so a converted\n * 176.67 °C reads \"180 °C\" and 356 °F reads \"350 °F\". `'none'` writes up to\n * three decimals.\n *\n * @throws {RecipeBookError} `invalid_argument` for an invalid temperature or option.\n *\n * @example\n * formatTemperature({ value: 176.66, unit: 'C' }, { rounding: 'oven' }) // \"180 °C\"\n * formatTemperature(convertTemperature({ value: 180, unit: 'C' }, 'F'), { rounding: 'oven' }) // \"350 °F\"\n * formatTemperature({ value: 350, unit: 'F' }, { unitDisplay: 'long' }) // \"350 degrees Fahrenheit\"\n */\nexport function formatTemperature(\n temperature: Temperature,\n options?: FormatTemperatureOptions,\n): string {\n const fn = 'formatTemperature'\n const { value, unit } = temperature ?? {}\n\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: value must be a finite number, received ${describeValue(value)}.`,\n )\n }\n if (unit !== 'C' && unit !== 'F') {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: temperature unit must be \"C\" or \"F\", received ${describeValue(unit)}.`,\n )\n }\n\n const { number, unitDisplay } = readSettings(fn, options)\n const rounding = option(fn, 'rounding', options?.rounding, [\n 'kitchen',\n 'oven',\n 'none',\n ])\n\n const digits = rounding === 'none' ? 3 : 0\n const shown =\n rounding === 'oven'\n ? round(value / OVEN_STEPS[unit], 0) * OVEN_STEPS[unit]\n : round(value, digits)\n const label =\n unitDisplay === 'short'\n ? `°${unit}`\n : `${pluralForm(shown) === 'one' ? 'degree' : 'degrees'} ${unit === 'C' ? 'Celsius' : 'Fahrenheit'}`\n\n return `${number(shown, digits)}${NBSP}${label}`\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAE/C,SAAS,MAAM,YAAY,aAAa;AACxC,SAA6B,QAAQ,oBAAoB;AAEzD,IAAM,aAAa,EAAE,GAAG,IAAI,GAAG,GAAG;AA2B3B,SAAS,kBACd,aACA,SACQ;AACR,QAAM,KAAK;AACX,QAAM,EAAE,OAAO,KAAK,IAAI,eAAe,CAAC;AAExC,MAAI,OAAO,UAAU,YAAY,CAAC,OAAO,SAAS,KAAK,GAAG;AACxD,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,6CAA6C,cAAc,KAAK,CAAC;AAAA,IACxE;AAAA,EACF;AACA,MAAI,SAAS,OAAO,SAAS,KAAK;AAChC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,mDAAmD,cAAc,IAAI,CAAC;AAAA,IAC7E;AAAA,EACF;AAEA,QAAM,EAAE,QAAQ,YAAY,IAAI,aAAa,IAAI,OAAO;AACxD,QAAM,WAAW,OAAO,IAAI,YAAY,SAAS,UAAU;AAAA,IACzD;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAED,QAAM,SAAS,aAAa,SAAS,IAAI;AACzC,QAAM,QACJ,aAAa,SACT,MAAM,QAAQ,WAAW,IAAI,GAAG,CAAC,IAAI,WAAW,IAAI,IACpD,MAAM,OAAO,MAAM;AACzB,QAAM,QACJ,gBAAgB,UACZ,OAAI,IAAI,KACR,GAAG,WAAW,KAAK,MAAM,QAAQ,WAAW,SAAS,IAAI,SAAS,MAAM,YAAY,YAAY;AAEtG,SAAO,GAAG,OAAO,OAAO,MAAM,CAAC,GAAG,IAAI,GAAG,KAAK;AAChD;","names":[]}
1
+ {"version":3,"sources":["../../src/format/temperature.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport type { Temperature } from '../units/temperature'\nimport { NBSP, pluralForm, round } from './number'\nimport { type FormatOptions, option, readSettings } from './options'\n\nconst OVEN_STEPS = { C: 10, F: 25 } as const\n\n/** Options for {@link formatTemperature}. */\nexport type FormatTemperatureOptions = FormatOptions & {\n /**\n * `'kitchen'` (default) writes whole degrees. `'oven'` rounds to the nearest\n * 10 °C or 25 °F. `'none'` writes up to three decimals.\n */\n rounding?: 'kitchen' | 'oven' | 'none' | undefined\n}\n\n/**\n * Formats a temperature, such as \"180 °C\" or \"350 degrees Fahrenheit\". The\n * number and unit are joined by a no-break space (U+00A0).\n *\n * Rounding is `'kitchen'` by default: whole degrees. `'oven'` rounds to the\n * steps oven dials and recipes use, the nearest 10 °C or 25 °F, so a converted\n * 176.67 °C reads \"180 °C\" and 356 °F reads \"350 °F\". `'none'` writes up to\n * three decimals.\n *\n * An error thrown by a getter or proxy in the input or options passes through.\n *\n * @throws {RecipeBookError} `invalid_argument` for an invalid temperature or option.\n *\n * @example\n * formatTemperature({ value: 176.66, unit: 'C' }, { rounding: 'oven' }) // \"180 °C\"\n * formatTemperature(convertTemperature({ value: 180, unit: 'C' }, 'F'), { rounding: 'oven' }) // \"350 °F\"\n * formatTemperature({ value: 350, unit: 'F' }, { unitDisplay: 'long' }) // \"350 degrees Fahrenheit\"\n */\nexport function formatTemperature(\n temperature: Temperature,\n options?: FormatTemperatureOptions,\n): string {\n const fn = 'formatTemperature'\n const { value, unit } = temperature ?? {}\n\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: value must be a finite number, received ${describeValue(value)}.`,\n )\n }\n if (unit !== 'C' && unit !== 'F') {\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: temperature unit must be \"C\" or \"F\", received ${describeValue(unit)}.`,\n )\n }\n\n const { number, unitDisplay } = readSettings(fn, options)\n const rounding = option(fn, 'rounding', options?.rounding, [\n 'kitchen',\n 'oven',\n 'none',\n ])\n\n const digits = rounding === 'none' ? 3 : 0\n const shown =\n rounding === 'oven'\n ? round(value / OVEN_STEPS[unit], 0) * OVEN_STEPS[unit]\n : round(value, digits)\n const label =\n unitDisplay === 'short'\n ? `°${unit}`\n : `${pluralForm(shown) === 'one' ? 'degree' : 'degrees'} ${unit === 'C' ? 'Celsius' : 'Fahrenheit'}`\n\n return `${number(shown, digits)}${NBSP}${label}`\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAE/C,SAAS,MAAM,YAAY,aAAa;AACxC,SAA6B,QAAQ,oBAAoB;AAEzD,IAAM,aAAa,EAAE,GAAG,IAAI,GAAG,GAAG;AA6B3B,SAAS,kBACd,aACA,SACQ;AACR,QAAM,KAAK;AACX,QAAM,EAAE,OAAO,KAAK,IAAI,eAAe,CAAC;AAExC,MAAI,OAAO,UAAU,YAAY,CAAC,OAAO,SAAS,KAAK,GAAG;AACxD,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,6CAA6C,cAAc,KAAK,CAAC;AAAA,IACxE;AAAA,EACF;AACA,MAAI,SAAS,OAAO,SAAS,KAAK;AAChC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,GAAG,EAAE,mDAAmD,cAAc,IAAI,CAAC;AAAA,IAC7E;AAAA,EACF;AAEA,QAAM,EAAE,QAAQ,YAAY,IAAI,aAAa,IAAI,OAAO;AACxD,QAAM,WAAW,OAAO,IAAI,YAAY,SAAS,UAAU;AAAA,IACzD;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAED,QAAM,SAAS,aAAa,SAAS,IAAI;AACzC,QAAM,QACJ,aAAa,SACT,MAAM,QAAQ,WAAW,IAAI,GAAG,CAAC,IAAI,WAAW,IAAI,IACpD,MAAM,OAAO,MAAM;AACzB,QAAM,QACJ,gBAAgB,UACZ,OAAI,IAAI,KACR,GAAG,WAAW,KAAK,MAAM,QAAQ,WAAW,SAAS,IAAI,SAAS,MAAM,YAAY,YAAY;AAEtG,SAAO,GAAG,OAAO,OAAO,MAAM,CAAC,GAAG,IAAI,GAAG,KAAK;AAChD;","names":[]}
package/dist/index.cjs CHANGED
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
- var chunkD5CV3UF7_cjs = require('./chunk-D5CV3UF7.cjs');
3
+ var chunkISRDJR2O_cjs = require('./chunk-ISRDJR2O.cjs');
4
4
 
5
5
  // src/recipe/schema.ts
6
6
  var recipeSchema = /* @__PURE__ */ standardSchema();
7
7
  function createRecipeSchema(options) {
8
- return standardSchema(chunkD5CV3UF7_cjs.readLineOption("createRecipeSchema", options));
8
+ return standardSchema(chunkISRDJR2O_cjs.readLineOption("createRecipeSchema", options));
9
9
  }
10
10
  function standardSchema(readLine) {
11
11
  const lines = readLine ?? "use createRecipeSchema({ parseIngredientLine })";
@@ -14,7 +14,7 @@ function standardSchema(readLine) {
14
14
  version: 1,
15
15
  vendor: "recipe-book",
16
16
  validate: (value) => {
17
- const result = chunkD5CV3UF7_cjs.parseRecipeWith(value, lines);
17
+ const result = chunkISRDJR2O_cjs.parseRecipeWith(value, lines);
18
18
  if (result.success) {
19
19
  return { value: result.data };
20
20
  }
@@ -28,9 +28,9 @@ function standardSchema(readLine) {
28
28
 
29
29
  // src/units/convert.ts
30
30
  function convert(quantity, to) {
31
- const amount = chunkD5CV3UF7_cjs.checkQuantity("convert", quantity);
32
- const target = chunkD5CV3UF7_cjs.getUnit(to);
33
- const source = quantity.unit === void 0 ? void 0 : chunkD5CV3UF7_cjs.getUnit(quantity.unit);
31
+ const amount = chunkISRDJR2O_cjs.checkQuantity("convert", quantity);
32
+ const target = chunkISRDJR2O_cjs.getUnit(to);
33
+ const source = quantity.unit === void 0 ? void 0 : chunkISRDJR2O_cjs.getUnit(quantity.unit);
34
34
  if (source?.id === target.id) {
35
35
  return { amount, unit: to };
36
36
  }
@@ -40,50 +40,50 @@ function convert(quantity, to) {
40
40
  const fromBase = source.toBase;
41
41
  const toBase = target.toBase;
42
42
  return {
43
- amount: chunkD5CV3UF7_cjs.checkResult(
43
+ amount: chunkISRDJR2O_cjs.checkResult(
44
44
  "convert",
45
- chunkD5CV3UF7_cjs.mapAmount(amount, (value) => value * fromBase / toBase)
45
+ chunkISRDJR2O_cjs.mapAmount(amount, (value) => value * fromBase / toBase)
46
46
  ),
47
47
  unit: to
48
48
  };
49
49
  }
50
50
  function canConvert(from, to) {
51
- const source = chunkD5CV3UF7_cjs.lookupUnit(from);
52
- const target = chunkD5CV3UF7_cjs.lookupUnit(to);
51
+ const source = chunkISRDJR2O_cjs.lookupUnit(from);
52
+ const target = chunkISRDJR2O_cjs.lookupUnit(to);
53
53
  if (!source || !target) {
54
54
  return false;
55
55
  }
56
56
  return source.id === target.id || source.toBase !== void 0 && source.dimension === target.dimension;
57
57
  }
58
58
  function toSystem(quantity, system) {
59
- const amount = chunkD5CV3UF7_cjs.checkQuantity("toSystem", quantity);
60
- chunkD5CV3UF7_cjs.checkSystem("toSystem", system);
59
+ const amount = chunkISRDJR2O_cjs.checkQuantity("toSystem", quantity);
60
+ chunkISRDJR2O_cjs.checkSystem("toSystem", system);
61
61
  const { unit } = quantity;
62
- if (unit !== void 0 && !chunkD5CV3UF7_cjs.lookupUnit(unit)) {
63
- throw chunkD5CV3UF7_cjs.unknownUnitError(unit);
62
+ if (unit !== void 0 && !chunkISRDJR2O_cjs.lookupUnit(unit)) {
63
+ throw chunkISRDJR2O_cjs.unknownUnitError(unit);
64
64
  }
65
65
  const checked = { amount, unit };
66
- const result = chunkD5CV3UF7_cjs.inSystem(checked, system);
67
- chunkD5CV3UF7_cjs.checkResult("toSystem", result.amount);
66
+ const result = chunkISRDJR2O_cjs.inSystem(checked, system);
67
+ chunkISRDJR2O_cjs.checkResult("toSystem", result.amount);
68
68
  return result === checked ? quantity : result;
69
69
  }
70
70
  function incompatibleUnits(source, target) {
71
71
  const example = target.dimension === "mass" ? "120 g" : target.dimension === "volume" ? "250 ml" : `1 ${target.id}`;
72
72
  const fix = `Add an equivalent to the ingredient, e.g. equivalents: ["${example}"].`;
73
73
  if (!source) {
74
- return new chunkD5CV3UF7_cjs.RecipeBookError(
74
+ return new chunkISRDJR2O_cjs.RecipeBookError(
75
75
  "incompatible_units",
76
76
  `Cannot convert a quantity without a unit to ${target.id}; a bare count such as "2 eggs" has no fixed size. ${fix}`
77
77
  );
78
78
  }
79
79
  const countUnit = [source, target].find((unit) => unit.toBase === void 0);
80
80
  if (countUnit) {
81
- return new chunkD5CV3UF7_cjs.RecipeBookError(
81
+ return new chunkISRDJR2O_cjs.RecipeBookError(
82
82
  "incompatible_units",
83
83
  `Cannot convert ${source.id} to ${target.id}; ${countUnit.id} is a count unit with no fixed size. ${fix}`
84
84
  );
85
85
  }
86
- return new chunkD5CV3UF7_cjs.RecipeBookError(
86
+ return new chunkISRDJR2O_cjs.RecipeBookError(
87
87
  "incompatible_units",
88
88
  `Cannot convert ${source.id} (${source.dimension}) to ${target.id} (${target.dimension}). ${fix}`
89
89
  );
@@ -94,17 +94,17 @@ function convertTemperature(temperature, to) {
94
94
  const { value, unit } = temperature ?? {};
95
95
  if (typeof value !== "number" || !Number.isFinite(value)) {
96
96
  throw invalidArgument(
97
- `value must be a finite number, received ${chunkD5CV3UF7_cjs.describeValue(value)}`
97
+ `value must be a finite number, received ${chunkISRDJR2O_cjs.describeValue(value)}`
98
98
  );
99
99
  }
100
100
  if (unit !== "C" && unit !== "F") {
101
101
  throw invalidArgument(
102
- `temperature unit must be "C" or "F", received ${chunkD5CV3UF7_cjs.describeValue(unit)}`
102
+ `temperature unit must be "C" or "F", received ${chunkISRDJR2O_cjs.describeValue(unit)}`
103
103
  );
104
104
  }
105
105
  if (to !== "C" && to !== "F") {
106
106
  throw invalidArgument(
107
- `target unit must be "C" or "F", received ${chunkD5CV3UF7_cjs.describeValue(to)}`
107
+ `target unit must be "C" or "F", received ${chunkISRDJR2O_cjs.describeValue(to)}`
108
108
  );
109
109
  }
110
110
  if (unit === to) {
@@ -119,7 +119,7 @@ function convertTemperature(temperature, to) {
119
119
  return { value: result, unit: to };
120
120
  }
121
121
  function invalidArgument(detail) {
122
- return new chunkD5CV3UF7_cjs.RecipeBookError(
122
+ return new chunkISRDJR2O_cjs.RecipeBookError(
123
123
  "invalid_argument",
124
124
  `convertTemperature: ${detail}.`
125
125
  );
@@ -131,27 +131,27 @@ function formatTemperature(temperature, options) {
131
131
  const fn = "formatTemperature";
132
132
  const { value, unit } = temperature ?? {};
133
133
  if (typeof value !== "number" || !Number.isFinite(value)) {
134
- throw new chunkD5CV3UF7_cjs.RecipeBookError(
134
+ throw new chunkISRDJR2O_cjs.RecipeBookError(
135
135
  "invalid_argument",
136
- `${fn}: value must be a finite number, received ${chunkD5CV3UF7_cjs.describeValue(value)}.`
136
+ `${fn}: value must be a finite number, received ${chunkISRDJR2O_cjs.describeValue(value)}.`
137
137
  );
138
138
  }
139
139
  if (unit !== "C" && unit !== "F") {
140
- throw new chunkD5CV3UF7_cjs.RecipeBookError(
140
+ throw new chunkISRDJR2O_cjs.RecipeBookError(
141
141
  "invalid_argument",
142
- `${fn}: temperature unit must be "C" or "F", received ${chunkD5CV3UF7_cjs.describeValue(unit)}.`
142
+ `${fn}: temperature unit must be "C" or "F", received ${chunkISRDJR2O_cjs.describeValue(unit)}.`
143
143
  );
144
144
  }
145
- const { number, unitDisplay } = chunkD5CV3UF7_cjs.readSettings(fn, options);
146
- const rounding = chunkD5CV3UF7_cjs.option(fn, "rounding", options?.rounding, [
145
+ const { number, unitDisplay } = chunkISRDJR2O_cjs.readSettings(fn, options);
146
+ const rounding = chunkISRDJR2O_cjs.option(fn, "rounding", options?.rounding, [
147
147
  "kitchen",
148
148
  "oven",
149
149
  "none"
150
150
  ]);
151
151
  const digits = rounding === "none" ? 3 : 0;
152
- const shown = rounding === "oven" ? chunkD5CV3UF7_cjs.round(value / OVEN_STEPS[unit], 0) * OVEN_STEPS[unit] : chunkD5CV3UF7_cjs.round(value, digits);
153
- const label = unitDisplay === "short" ? `\xB0${unit}` : `${chunkD5CV3UF7_cjs.pluralForm(shown) === "one" ? "degree" : "degrees"} ${unit === "C" ? "Celsius" : "Fahrenheit"}`;
154
- return `${number(shown, digits)}${chunkD5CV3UF7_cjs.NBSP}${label}`;
152
+ const shown = rounding === "oven" ? chunkISRDJR2O_cjs.round(value / OVEN_STEPS[unit], 0) * OVEN_STEPS[unit] : chunkISRDJR2O_cjs.round(value, digits);
153
+ const label = unitDisplay === "short" ? `\xB0${unit}` : `${chunkISRDJR2O_cjs.pluralForm(shown) === "one" ? "degree" : "degrees"} ${unit === "C" ? "Celsius" : "Fahrenheit"}`;
154
+ return `${number(shown, digits)}${chunkISRDJR2O_cjs.NBSP}${label}`;
155
155
  }
156
156
 
157
157
  // src/format/duration.ts
@@ -163,12 +163,12 @@ var SHORT_LABELS = {
163
163
  function formatDuration(minutes, options) {
164
164
  const fn = "formatDuration";
165
165
  if (!Number.isFinite(minutes) || minutes < 0) {
166
- throw new chunkD5CV3UF7_cjs.RecipeBookError(
166
+ throw new chunkISRDJR2O_cjs.RecipeBookError(
167
167
  "invalid_argument",
168
- `${fn}: minutes must be a finite number, 0 or more, received ${chunkD5CV3UF7_cjs.describeValue(minutes)}.`
168
+ `${fn}: minutes must be a finite number, 0 or more, received ${chunkISRDJR2O_cjs.describeValue(minutes)}.`
169
169
  );
170
170
  }
171
- const { number, unitDisplay } = chunkD5CV3UF7_cjs.readSettings(fn, options);
171
+ const { number, unitDisplay } = chunkISRDJR2O_cjs.readSettings(fn, options);
172
172
  const partDay = minutes % 1440;
173
173
  const seconds = Math.round(partDay * 60);
174
174
  const days = Math.round((minutes - partDay) / 1440) + (seconds === 86400 ? 1 : 0);
@@ -180,7 +180,7 @@ function formatDuration(minutes, options) {
180
180
  ];
181
181
  const parts = counts.filter(([, count]) => count > 0);
182
182
  return (parts.length > 0 ? parts : [["minute", 0]]).map(
183
- ([unit, count]) => `${number(count, 0)}${chunkD5CV3UF7_cjs.NBSP}${unitDisplay === "short" && SHORT_LABELS[unit] || (chunkD5CV3UF7_cjs.pluralForm(count) === "one" ? unit : `${unit}s`)}`
183
+ ([unit, count]) => `${number(count, 0)}${chunkISRDJR2O_cjs.NBSP}${unitDisplay === "short" && SHORT_LABELS[unit] || (chunkISRDJR2O_cjs.pluralForm(count) === "one" ? unit : `${unit}s`)}`
184
184
  ).join(" ");
185
185
  }
186
186
 
@@ -209,7 +209,7 @@ var SIGN = /[-\u2212]/;
209
209
  var SIGN_BEFORE = /[\s(]/;
210
210
  var LETTER = /[a-z]/i;
211
211
  function matchTemperature(text, digit, floor, assumed) {
212
- const negative = digit > floor && SIGN.test(text[digit - 1] ?? "") && SIGN_BEFORE.test(text.charAt(digit - 2) || " ");
212
+ const negative = digit > floor && SIGN.test(text[digit - 1] ?? "") && SIGN_BEFORE.test(text.charAt(digit - 2) || " ") && !SHARED.test(text.slice(Math.max(floor, digit - LOOKBACK), digit));
213
213
  const start = negative ? digit - 1 : digit;
214
214
  const before = text.slice(Math.max(floor, start - LOOKBACK), start);
215
215
  if (LETTER.test(text.charAt(start - 1)) || NOT_A_READING.test(before) || SHARED.test(before) && !GAS_ALTERNATIVE.test(before)) {
@@ -358,16 +358,16 @@ function at(pattern, text, index) {
358
358
  // src/steps/segment.ts
359
359
  function segmentStepText(text, options) {
360
360
  if (typeof text !== "string") {
361
- throw new chunkD5CV3UF7_cjs.RecipeBookError(
361
+ throw new chunkISRDJR2O_cjs.RecipeBookError(
362
362
  "invalid_argument",
363
- `segmentStepText: text must be a string, received ${chunkD5CV3UF7_cjs.describeValue(text)}.`
363
+ `segmentStepText: text must be a string, received ${chunkISRDJR2O_cjs.describeValue(text)}.`
364
364
  );
365
365
  }
366
366
  const assumed = options?.assumeTemperatureUnit;
367
367
  if (assumed !== void 0 && assumed !== "C" && assumed !== "F") {
368
- throw new chunkD5CV3UF7_cjs.RecipeBookError(
368
+ throw new chunkISRDJR2O_cjs.RecipeBookError(
369
369
  "invalid_argument",
370
- `segmentStepText: assumeTemperatureUnit must be "C" or "F", received ${chunkD5CV3UF7_cjs.describeValue(assumed)}.`
370
+ `segmentStepText: assumeTemperatureUnit must be "C" or "F", received ${chunkISRDJR2O_cjs.describeValue(assumed)}.`
371
371
  );
372
372
  }
373
373
  const segments = [];
@@ -397,71 +397,75 @@ function segmentStepText(text, options) {
397
397
 
398
398
  Object.defineProperty(exports, "RecipeBookError", {
399
399
  enumerable: true,
400
- get: function () { return chunkD5CV3UF7_cjs.RecipeBookError; }
400
+ get: function () { return chunkISRDJR2O_cjs.RecipeBookError; }
401
401
  });
402
402
  Object.defineProperty(exports, "RecipeValidationError", {
403
403
  enumerable: true,
404
- get: function () { return chunkD5CV3UF7_cjs.RecipeValidationError; }
404
+ get: function () { return chunkISRDJR2O_cjs.RecipeValidationError; }
405
405
  });
406
406
  Object.defineProperty(exports, "findUnit", {
407
407
  enumerable: true,
408
- get: function () { return chunkD5CV3UF7_cjs.findUnit; }
408
+ get: function () { return chunkISRDJR2O_cjs.findUnit; }
409
409
  });
410
410
  Object.defineProperty(exports, "formatIngredient", {
411
411
  enumerable: true,
412
- get: function () { return chunkD5CV3UF7_cjs.formatIngredient; }
412
+ get: function () { return chunkISRDJR2O_cjs.formatIngredient; }
413
413
  });
414
414
  Object.defineProperty(exports, "formatQuantity", {
415
415
  enumerable: true,
416
- get: function () { return chunkD5CV3UF7_cjs.formatQuantity; }
416
+ get: function () { return chunkISRDJR2O_cjs.formatQuantity; }
417
417
  });
418
418
  Object.defineProperty(exports, "formatQuantityToParts", {
419
419
  enumerable: true,
420
- get: function () { return chunkD5CV3UF7_cjs.formatQuantityToParts; }
420
+ get: function () { return chunkISRDJR2O_cjs.formatQuantityToParts; }
421
421
  });
422
422
  Object.defineProperty(exports, "formatServings", {
423
423
  enumerable: true,
424
- get: function () { return chunkD5CV3UF7_cjs.formatServings; }
424
+ get: function () { return chunkISRDJR2O_cjs.formatServings; }
425
425
  });
426
426
  Object.defineProperty(exports, "formatYield", {
427
427
  enumerable: true,
428
- get: function () { return chunkD5CV3UF7_cjs.formatYield; }
428
+ get: function () { return chunkISRDJR2O_cjs.formatYield; }
429
429
  });
430
430
  Object.defineProperty(exports, "getUnit", {
431
431
  enumerable: true,
432
- get: function () { return chunkD5CV3UF7_cjs.getUnit; }
432
+ get: function () { return chunkISRDJR2O_cjs.getUnit; }
433
433
  });
434
434
  Object.defineProperty(exports, "isRecipeBookError", {
435
435
  enumerable: true,
436
- get: function () { return chunkD5CV3UF7_cjs.isRecipeBookError; }
436
+ get: function () { return chunkISRDJR2O_cjs.isRecipeBookError; }
437
+ });
438
+ Object.defineProperty(exports, "isRecipeValidationError", {
439
+ enumerable: true,
440
+ get: function () { return chunkISRDJR2O_cjs.isRecipeValidationError; }
437
441
  });
438
442
  Object.defineProperty(exports, "parseIngredientLine", {
439
443
  enumerable: true,
440
- get: function () { return chunkD5CV3UF7_cjs.parseIngredientLine; }
444
+ get: function () { return chunkISRDJR2O_cjs.parseIngredientLine; }
441
445
  });
442
446
  Object.defineProperty(exports, "parseQuantity", {
443
447
  enumerable: true,
444
- get: function () { return chunkD5CV3UF7_cjs.parseQuantity; }
448
+ get: function () { return chunkISRDJR2O_cjs.parseQuantity; }
445
449
  });
446
450
  Object.defineProperty(exports, "parseRecipe", {
447
451
  enumerable: true,
448
- get: function () { return chunkD5CV3UF7_cjs.parseRecipe; }
452
+ get: function () { return chunkISRDJR2O_cjs.parseRecipe; }
449
453
  });
450
454
  Object.defineProperty(exports, "resolveIngredient", {
451
455
  enumerable: true,
452
- get: function () { return chunkD5CV3UF7_cjs.resolveIngredient; }
456
+ get: function () { return chunkISRDJR2O_cjs.resolveIngredient; }
453
457
  });
454
458
  Object.defineProperty(exports, "resolveRecipe", {
455
459
  enumerable: true,
456
- get: function () { return chunkD5CV3UF7_cjs.resolveRecipe; }
460
+ get: function () { return chunkISRDJR2O_cjs.resolveRecipe; }
457
461
  });
458
462
  Object.defineProperty(exports, "scaleQuantity", {
459
463
  enumerable: true,
460
- get: function () { return chunkD5CV3UF7_cjs.scaleQuantity; }
464
+ get: function () { return chunkISRDJR2O_cjs.scaleQuantity; }
461
465
  });
462
466
  Object.defineProperty(exports, "units", {
463
467
  enumerable: true,
464
- get: function () { return chunkD5CV3UF7_cjs.units; }
468
+ get: function () { return chunkISRDJR2O_cjs.units; }
465
469
  });
466
470
  exports.canConvert = canConvert;
467
471
  exports.convert = convert;