@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.
- package/CHANGELOG.md +35 -0
- package/dist/{chunk-D5CV3UF7.cjs → chunk-ISRDJR2O.cjs} +42 -9
- package/dist/chunk-ISRDJR2O.cjs.map +1 -0
- package/dist/errors.js +29 -1
- package/dist/errors.js.map +1 -1
- package/dist/format/duration.js.map +1 -1
- package/dist/format/ingredient.js.map +1 -1
- package/dist/format/quantity.js.map +1 -1
- package/dist/format/servings.js.map +1 -1
- package/dist/format/temperature.js.map +1 -1
- package/dist/index.cjs +62 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -11
- package/dist/index.d.ts +38 -11
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/quantity/parse.js.map +1 -1
- package/dist/quantity/scale.js.map +1 -1
- package/dist/recipe/ids.js.map +1 -1
- package/dist/recipe/parse.js +11 -6
- package/dist/recipe/parse.js.map +1 -1
- package/dist/resolve.js.map +1 -1
- package/dist/schema-org/from-json-ld.js +16 -23
- package/dist/schema-org/from-json-ld.js.map +1 -1
- package/dist/schema-org/to-json-ld.js.map +1 -1
- package/dist/schema-org.cjs +75 -82
- package/dist/schema-org.cjs.map +1 -1
- package/dist/schema-org.d.cts +3 -2
- package/dist/schema-org.d.ts +3 -2
- package/dist/steps/segment.js.map +1 -1
- package/dist/steps/temperature.js +1 -1
- package/dist/steps/temperature.js.map +1 -1
- package/dist/{types-BknM8KOd.d.cts → types-CKIoWsz8.d.cts} +21 -4
- package/dist/{types-BknM8KOd.d.ts → types-CKIoWsz8.d.ts} +21 -4
- package/dist/units/convert.js.map +1 -1
- package/dist/units/temperature.js.map +1 -1
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- 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
|
};
|
package/dist/errors.js.map
CHANGED
|
@@ -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
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
32
|
-
const target =
|
|
33
|
-
const source = quantity.unit === void 0 ? void 0 :
|
|
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:
|
|
43
|
+
amount: chunkISRDJR2O_cjs.checkResult(
|
|
44
44
|
"convert",
|
|
45
|
-
|
|
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 =
|
|
52
|
-
const target =
|
|
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 =
|
|
60
|
-
|
|
59
|
+
const amount = chunkISRDJR2O_cjs.checkQuantity("toSystem", quantity);
|
|
60
|
+
chunkISRDJR2O_cjs.checkSystem("toSystem", system);
|
|
61
61
|
const { unit } = quantity;
|
|
62
|
-
if (unit !== void 0 && !
|
|
63
|
-
throw
|
|
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 =
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
|
134
|
+
throw new chunkISRDJR2O_cjs.RecipeBookError(
|
|
135
135
|
"invalid_argument",
|
|
136
|
-
`${fn}: value must be a finite number, received ${
|
|
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
|
|
140
|
+
throw new chunkISRDJR2O_cjs.RecipeBookError(
|
|
141
141
|
"invalid_argument",
|
|
142
|
-
`${fn}: temperature unit must be "C" or "F", received ${
|
|
142
|
+
`${fn}: temperature unit must be "C" or "F", received ${chunkISRDJR2O_cjs.describeValue(unit)}.`
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
|
-
const { number, unitDisplay } =
|
|
146
|
-
const 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" ?
|
|
153
|
-
const label = unitDisplay === "short" ? `\xB0${unit}` : `${
|
|
154
|
-
return `${number(shown, digits)}${
|
|
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
|
|
166
|
+
throw new chunkISRDJR2O_cjs.RecipeBookError(
|
|
167
167
|
"invalid_argument",
|
|
168
|
-
`${fn}: minutes must be a finite number, 0 or more, received ${
|
|
168
|
+
`${fn}: minutes must be a finite number, 0 or more, received ${chunkISRDJR2O_cjs.describeValue(minutes)}.`
|
|
169
169
|
);
|
|
170
170
|
}
|
|
171
|
-
const { number, unitDisplay } =
|
|
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)}${
|
|
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
|
|
361
|
+
throw new chunkISRDJR2O_cjs.RecipeBookError(
|
|
362
362
|
"invalid_argument",
|
|
363
|
-
`segmentStepText: text must be a string, received ${
|
|
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
|
|
368
|
+
throw new chunkISRDJR2O_cjs.RecipeBookError(
|
|
369
369
|
"invalid_argument",
|
|
370
|
-
`segmentStepText: assumeTemperatureUnit must be "C" or "F", received ${
|
|
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
|
|
400
|
+
get: function () { return chunkISRDJR2O_cjs.RecipeBookError; }
|
|
401
401
|
});
|
|
402
402
|
Object.defineProperty(exports, "RecipeValidationError", {
|
|
403
403
|
enumerable: true,
|
|
404
|
-
get: function () { return
|
|
404
|
+
get: function () { return chunkISRDJR2O_cjs.RecipeValidationError; }
|
|
405
405
|
});
|
|
406
406
|
Object.defineProperty(exports, "findUnit", {
|
|
407
407
|
enumerable: true,
|
|
408
|
-
get: function () { return
|
|
408
|
+
get: function () { return chunkISRDJR2O_cjs.findUnit; }
|
|
409
409
|
});
|
|
410
410
|
Object.defineProperty(exports, "formatIngredient", {
|
|
411
411
|
enumerable: true,
|
|
412
|
-
get: function () { return
|
|
412
|
+
get: function () { return chunkISRDJR2O_cjs.formatIngredient; }
|
|
413
413
|
});
|
|
414
414
|
Object.defineProperty(exports, "formatQuantity", {
|
|
415
415
|
enumerable: true,
|
|
416
|
-
get: function () { return
|
|
416
|
+
get: function () { return chunkISRDJR2O_cjs.formatQuantity; }
|
|
417
417
|
});
|
|
418
418
|
Object.defineProperty(exports, "formatQuantityToParts", {
|
|
419
419
|
enumerable: true,
|
|
420
|
-
get: function () { return
|
|
420
|
+
get: function () { return chunkISRDJR2O_cjs.formatQuantityToParts; }
|
|
421
421
|
});
|
|
422
422
|
Object.defineProperty(exports, "formatServings", {
|
|
423
423
|
enumerable: true,
|
|
424
|
-
get: function () { return
|
|
424
|
+
get: function () { return chunkISRDJR2O_cjs.formatServings; }
|
|
425
425
|
});
|
|
426
426
|
Object.defineProperty(exports, "formatYield", {
|
|
427
427
|
enumerable: true,
|
|
428
|
-
get: function () { return
|
|
428
|
+
get: function () { return chunkISRDJR2O_cjs.formatYield; }
|
|
429
429
|
});
|
|
430
430
|
Object.defineProperty(exports, "getUnit", {
|
|
431
431
|
enumerable: true,
|
|
432
|
-
get: function () { return
|
|
432
|
+
get: function () { return chunkISRDJR2O_cjs.getUnit; }
|
|
433
433
|
});
|
|
434
434
|
Object.defineProperty(exports, "isRecipeBookError", {
|
|
435
435
|
enumerable: true,
|
|
436
|
-
get: function () { return
|
|
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
|
|
444
|
+
get: function () { return chunkISRDJR2O_cjs.parseIngredientLine; }
|
|
441
445
|
});
|
|
442
446
|
Object.defineProperty(exports, "parseQuantity", {
|
|
443
447
|
enumerable: true,
|
|
444
|
-
get: function () { return
|
|
448
|
+
get: function () { return chunkISRDJR2O_cjs.parseQuantity; }
|
|
445
449
|
});
|
|
446
450
|
Object.defineProperty(exports, "parseRecipe", {
|
|
447
451
|
enumerable: true,
|
|
448
|
-
get: function () { return
|
|
452
|
+
get: function () { return chunkISRDJR2O_cjs.parseRecipe; }
|
|
449
453
|
});
|
|
450
454
|
Object.defineProperty(exports, "resolveIngredient", {
|
|
451
455
|
enumerable: true,
|
|
452
|
-
get: function () { return
|
|
456
|
+
get: function () { return chunkISRDJR2O_cjs.resolveIngredient; }
|
|
453
457
|
});
|
|
454
458
|
Object.defineProperty(exports, "resolveRecipe", {
|
|
455
459
|
enumerable: true,
|
|
456
|
-
get: function () { return
|
|
460
|
+
get: function () { return chunkISRDJR2O_cjs.resolveRecipe; }
|
|
457
461
|
});
|
|
458
462
|
Object.defineProperty(exports, "scaleQuantity", {
|
|
459
463
|
enumerable: true,
|
|
460
|
-
get: function () { return
|
|
464
|
+
get: function () { return chunkISRDJR2O_cjs.scaleQuantity; }
|
|
461
465
|
});
|
|
462
466
|
Object.defineProperty(exports, "units", {
|
|
463
467
|
enumerable: true,
|
|
464
|
-
get: function () { return
|
|
468
|
+
get: function () { return chunkISRDJR2O_cjs.units; }
|
|
465
469
|
});
|
|
466
470
|
exports.canConvert = canConvert;
|
|
467
471
|
exports.convert = convert;
|