@recipe-book/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/README.md +1 -1
- package/dist/{chunk-VWVIBCTG.cjs → chunk-ISRDJR2O.cjs} +227 -179
- package/dist/chunk-ISRDJR2O.cjs.map +1 -0
- package/dist/errors.js +29 -1
- package/dist/errors.js.map +1 -1
- package/dist/format/duration.js.map +1 -1
- package/dist/format/ingredient.js.map +1 -1
- package/dist/format/number.js +1 -1
- package/dist/format/number.js.map +1 -1
- package/dist/format/options.js.map +1 -1
- package/dist/format/quantity.js.map +1 -1
- package/dist/format/servings.js.map +1 -1
- package/dist/format/temperature.js.map +1 -1
- package/dist/index.cjs +87 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -28
- package/dist/index.d.ts +71 -28
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/ingredient/parse.js +27 -22
- package/dist/ingredient/parse.js.map +1 -1
- package/dist/issues.js.map +1 -1
- package/dist/quantity/amount.js +11 -0
- package/dist/quantity/amount.js.map +1 -1
- package/dist/quantity/parse.js +58 -81
- package/dist/quantity/parse.js.map +1 -1
- package/dist/quantity/scale.js +5 -2
- package/dist/quantity/scale.js.map +1 -1
- package/dist/recipe/ids.js.map +1 -1
- package/dist/recipe/parse.js +80 -60
- package/dist/recipe/parse.js.map +1 -1
- package/dist/recipe/schema.js +8 -6
- package/dist/recipe/schema.js.map +1 -1
- package/dist/resolve.js +6 -1
- package/dist/resolve.js.map +1 -1
- package/dist/schema-org/find.js +46 -13
- package/dist/schema-org/find.js.map +1 -1
- package/dist/schema-org/from-json-ld.js +134 -71
- package/dist/schema-org/from-json-ld.js.map +1 -1
- package/dist/schema-org/text.js.map +1 -1
- package/dist/schema-org/to-json-ld.js +12 -15
- package/dist/schema-org/to-json-ld.js.map +1 -1
- package/dist/schema-org.cjs +239 -147
- package/dist/schema-org.cjs.map +1 -1
- package/dist/schema-org.d.cts +35 -21
- package/dist/schema-org.d.ts +35 -21
- package/dist/steps/segment.js.map +1 -1
- package/dist/steps/temperature.js +13 -13
- package/dist/steps/temperature.js.map +1 -1
- package/dist/{types-DSMf45H5.d.cts → types-CKIoWsz8.d.cts} +25 -7
- package/dist/{types-DSMf45H5.d.ts → types-CKIoWsz8.d.ts} +25 -7
- package/dist/units/convert.js +16 -8
- package/dist/units/convert.js.map +1 -1
- package/dist/units/temperature.js +7 -1
- package/dist/units/temperature.js.map +1 -1
- package/dist/validation.js +1 -1
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-VWVIBCTG.cjs.map +0 -1
package/dist/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"]}
|
package/dist/format/number.js
CHANGED
|
@@ -107,7 +107,7 @@ function integerText(value) {
|
|
|
107
107
|
function invalidLocale(fn, locale) {
|
|
108
108
|
return new RecipeBookError(
|
|
109
109
|
"invalid_argument",
|
|
110
|
-
`${fn}: locale must be a
|
|
110
|
+
`${fn}: locale must be a language tag that Intl accepts, such as "en-US", received ${describeValue(locale)}.`
|
|
111
111
|
);
|
|
112
112
|
}
|
|
113
113
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/format/number.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\n\n// Literals, not `10 ** n`: Math.pow is implementation-approximated, so engines may disagree.\nconst POWERS = [\n 1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14,\n 1e15,\n] as const\nconst MAX_DIGITS = 15\n\n/** Joins a number to its unit so the two never wrap apart. */\nexport const NBSP = '\\u00A0'\n\nexport type PluralForm = 'one' | 'other'\n\n/** Writes a value already rounded to `digits` fraction digits, without trailing zeros or grouping. */\nexport type NumberWriter = (value: number, digits: number) => string\n\n/**\n * Rounds half away from zero to `digits` fraction digits (0 to 15), at 15\n * significant digits so 1.005 rounds to 1.01 although its double is just\n * below. When `digits` would need more than 15 significant digits, rounds to a\n * whole number. Never returns -0.\n */\nexport function round(value: number, digits: number): number {\n const power = POWERS[digits]!\n const magnitude = Math.abs(value)\n const scaled = magnitude * power\n const rounded =\n scaled < 1e15\n ? Math.round(Number(scaled.toPrecision(15))) / power\n : Math.round(magnitude)\n\n return value < 0 && rounded !== 0 ? -rounded : rounded\n}\n\n/**\n * Rounds a positive value to at least `digits` fraction digits, adding digits\n * until the result is not 0, so a small amount never reads as 0.\n */\nexport function roundAboveZero(\n value: number,\n digits: number,\n): [rounded: number, digits: number] {\n for (let used = digits; used <= MAX_DIGITS; used++) {\n const rounded = round(value, used)\n if (rounded > 0) {\n return [rounded, used]\n }\n }\n\n return [1e-15, MAX_DIGITS]\n}\n\n// Keyed by caller input, which may come from a request, so the cache is bounded.\nconst writers = new Map<string, NumberWriter>()\nconst MAX_WRITERS = 32\n\n/**\n * The number writer for `locale`. English (`en` or `en-` and a region) is\n * hand-written, so the default output never depends on the engine's ICU data.\n * Other tags, including English ones with extensions such as\n * `en-US-u-nu-arab`, use `Intl` with the explicit locale; unsupported ones\n * fall back to English rather than to the runtime's default locale.\n *\n * @throws {RecipeBookError} `invalid_argument` when `locale` is
|
|
1
|
+
{"version":3,"sources":["../../src/format/number.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\n\n// Literals, not `10 ** n`: Math.pow is implementation-approximated, so engines may disagree.\nconst POWERS = [\n 1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14,\n 1e15,\n] as const\nconst MAX_DIGITS = 15\n\n/** Joins a number to its unit so the two never wrap apart. */\nexport const NBSP = '\\u00A0'\n\nexport type PluralForm = 'one' | 'other'\n\n/** Writes a value already rounded to `digits` fraction digits, without trailing zeros or grouping. */\nexport type NumberWriter = (value: number, digits: number) => string\n\n/**\n * Rounds half away from zero to `digits` fraction digits (0 to 15), at 15\n * significant digits so 1.005 rounds to 1.01 although its double is just\n * below. When `digits` would need more than 15 significant digits, rounds to a\n * whole number. Never returns -0.\n */\nexport function round(value: number, digits: number): number {\n const power = POWERS[digits]!\n const magnitude = Math.abs(value)\n const scaled = magnitude * power\n const rounded =\n scaled < 1e15\n ? Math.round(Number(scaled.toPrecision(15))) / power\n : Math.round(magnitude)\n\n return value < 0 && rounded !== 0 ? -rounded : rounded\n}\n\n/**\n * Rounds a positive value to at least `digits` fraction digits, adding digits\n * until the result is not 0, so a small amount never reads as 0.\n */\nexport function roundAboveZero(\n value: number,\n digits: number,\n): [rounded: number, digits: number] {\n for (let used = digits; used <= MAX_DIGITS; used++) {\n const rounded = round(value, used)\n if (rounded > 0) {\n return [rounded, used]\n }\n }\n\n return [1e-15, MAX_DIGITS]\n}\n\n// Keyed by caller input, which may come from a request, so the cache is bounded.\nconst writers = new Map<string, NumberWriter>()\nconst MAX_WRITERS = 32\n\n/**\n * The number writer for `locale`. English (`en` or `en-` and a region) is\n * hand-written, so the default output never depends on the engine's ICU data.\n * Other tags, including English ones with extensions such as\n * `en-US-u-nu-arab`, use `Intl` with the explicit locale; unsupported ones\n * fall back to English rather than to the runtime's default locale.\n *\n * @throws {RecipeBookError} `invalid_argument` when `locale` isn't a string or\n * is one `Intl` rejects, such as `en_US`. `Intl` also rejects some BCP 47\n * tags, such as `i-klingon` and `x-private`. Without `Intl`, only a `locale`\n * that isn't a string throws, and every string writes English.\n */\nexport function numberWriter(\n fn: string,\n locale: unknown = 'en-US',\n): NumberWriter {\n if (typeof locale !== 'string') {\n throw invalidLocale(fn, locale)\n }\n\n if (\n /^en(?:-(?:[a-z]{2}|\\d{3}))?$/i.test(locale) ||\n typeof Intl === 'undefined'\n ) {\n return englishNumber\n }\n\n let writer = writers.get(locale)\n if (!writer) {\n writer = intlNumber(fn, locale)\n if (writer !== englishNumber) {\n if (writers.size >= MAX_WRITERS) {\n writers.clear()\n }\n writers.set(locale, writer)\n }\n }\n\n return writer\n}\n\n/**\n * The English plural form of a number written in digits, which every label\n * uses until labels are localized: `one` for 1, `other` for anything else,\n * including 0 and decimals (\"0.5 cups\").\n */\nexport function pluralForm(value: number): PluralForm {\n return Math.abs(value) === 1 ? 'one' : 'other'\n}\n\nfunction intlNumber(fn: string, locale: string): NumberWriter {\n let supported: readonly string[]\n try {\n supported = Intl.NumberFormat.supportedLocalesOf(locale)\n } catch {\n throw invalidLocale(fn, locale)\n }\n\n if (supported.length === 0) {\n return englishNumber\n }\n\n const formats: Intl.NumberFormat[] = []\n return (value, digits) => {\n let format = formats[digits]\n if (!format) {\n format = new Intl.NumberFormat(locale, {\n useGrouping: false,\n maximumFractionDigits: digits,\n })\n formats[digits] = format\n }\n\n return format.format(value)\n }\n}\n\nfunction englishNumber(value: number, digits: number): string {\n const magnitude = Math.abs(value)\n const units = Math.round(magnitude * POWERS[digits]!)\n const sign = value < 0 ? '-' : ''\n\n if (digits === 0 || units > Number.MAX_SAFE_INTEGER) {\n return sign + integerText(magnitude)\n }\n\n const text = String(units).padStart(digits + 1, '0')\n const whole = text.slice(0, -digits)\n const fraction = text.slice(-digits).replace(/0+$/, '')\n\n return sign + (fraction ? `${whole}.${fraction}` : whole)\n}\n\n/** Writes a non-negative integer in plain digits; `String` switches to exponent notation from 1e21. */\nfunction integerText(value: number): string {\n const text = String(value)\n const match = /^(\\d)(?:\\.(\\d+))?e\\+(\\d+)$/.exec(text)\n\n if (!match) {\n return text\n }\n\n const [, head, tail = '', exponent] = match\n return head + tail.padEnd(Number(exponent), '0')\n}\n\nfunction invalidLocale(fn: string, locale: unknown): RecipeBookError {\n return new RecipeBookError(\n 'invalid_argument',\n `${fn}: locale must be a language tag that Intl accepts, such as \"en-US\", received ${describeValue(locale)}.`,\n )\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/format/options.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { type NumberWriter, numberWriter } from './number'\n\n/**\n * Options every `format*` function takes. Every default is a fixed value,\n * never read from the runtime, so server and client render the same text.\n */\nexport type FormatOptions = {\n /**\n *
|
|
1
|
+
{"version":3,"sources":["../../src/format/options.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { type NumberWriter, numberWriter } from './number'\n\n/**\n * Options every `format*` function takes. Every default is a fixed value,\n * never read from the runtime, so server and client render the same text.\n */\nexport type FormatOptions = {\n /**\n * A language tag that `Intl` accepts, such as `'en-US'`, the default. Labels\n * and plurals are English in 0.1; only numbers follow the locale, and they\n * are never grouped. Numbers in other languages come from the runtime's\n * `Intl` data, so the server and client need the same locale support to\n * render the same text. A locale the runtime doesn't support falls back to\n * English, but a string `Intl` rejects throws. `Intl` rejects `'en_US'`, and\n * some BCP 47 tags too, such as `'i-klingon'` and `'x-private'`.\n */\n locale?: string | undefined\n /** `'short'` (`tbsp`) or `'long'` (`tablespoons`). Default `'short'`. */\n unitDisplay?: 'short' | 'long' | undefined\n}\n\nexport type Settings = {\n number: NumberWriter\n unitDisplay: 'short' | 'long'\n}\n\nexport function readSettings(\n fn: string,\n options: FormatOptions | undefined,\n): Settings {\n return {\n number: numberWriter(fn, options?.locale),\n unitDisplay: option(fn, 'unitDisplay', options?.unitDisplay, [\n 'short',\n 'long',\n ]),\n }\n}\n\n/**\n * Reads an enumerated option; the first allowed value is the default.\n *\n * @throws {RecipeBookError} `invalid_argument` for any other value.\n */\nexport function option<const T extends string | boolean>(\n fn: string,\n name: string,\n value: unknown,\n allowed: readonly [T, ...T[]],\n): T {\n if (value === undefined) {\n return allowed[0]\n }\n\n if ((allowed as readonly unknown[]).includes(value)) {\n return value as T\n }\n\n const choices = allowed.map((choice) => JSON.stringify(choice))\n const last = choices.pop()\n throw new RecipeBookError(\n 'invalid_argument',\n `${fn}: ${name} must be ${choices.join(', ')}${choices.length > 1 ? ',' : ''} or ${last}, received ${describeValue(value)}.`,\n )\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAC/C,SAA4B,oBAAoB;AA0BzC,SAAS,aACd,IACA,SACU;AACV,SAAO;AAAA,IACL,QAAQ,aAAa,IAAI,SAAS,MAAM;AAAA,IACxC,aAAa,OAAO,IAAI,eAAe,SAAS,aAAa;AAAA,MAC3D;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAOO,SAAS,OACd,IACA,MACA,OACA,SACG;AACH,MAAI,UAAU,QAAW;AACvB,WAAO,QAAQ,CAAC;AAAA,EAClB;AAEA,MAAK,QAA+B,SAAS,KAAK,GAAG;AACnD,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,QAAQ,IAAI,CAAC,WAAW,KAAK,UAAU,MAAM,CAAC;AAC9D,QAAM,OAAO,QAAQ,IAAI;AACzB,QAAM,IAAI;AAAA,IACR;AAAA,IACA,GAAG,EAAE,KAAK,IAAI,YAAY,QAAQ,KAAK,IAAI,CAAC,GAAG,QAAQ,SAAS,IAAI,MAAM,EAAE,OAAO,IAAI,cAAc,cAAc,KAAK,CAAC;AAAA,EAC3H;AACF;","names":[]}
|
|
@@ -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":[]}
|