@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/quantity/parse.js
CHANGED
|
@@ -8,45 +8,9 @@ import { findUnit, suggestUnit } from "../units/lookup.js";
|
|
|
8
8
|
import { units } from "../units/registry.js";
|
|
9
9
|
import { asRecord, checkFields, error, toResult } from "../validation.js";
|
|
10
10
|
import { isPositive } from "./amount.js";
|
|
11
|
-
var
|
|
12
|
-
"\xBC": [1, 4],
|
|
13
|
-
"\xBD": [1, 2],
|
|
14
|
-
"\xBE": [3, 4],
|
|
15
|
-
"\u2150": [1, 7],
|
|
16
|
-
"\u2151": [1, 9],
|
|
17
|
-
"\u2152": [1, 10],
|
|
18
|
-
"\u2153": [1, 3],
|
|
19
|
-
"\u2154": [2, 3],
|
|
20
|
-
"\u2155": [1, 5],
|
|
21
|
-
"\u2156": [2, 5],
|
|
22
|
-
"\u2157": [3, 5],
|
|
23
|
-
"\u2158": [4, 5],
|
|
24
|
-
"\u2159": [1, 6],
|
|
25
|
-
"\u215A": [5, 6],
|
|
26
|
-
"\u215B": [1, 8],
|
|
27
|
-
"\u215C": [3, 8],
|
|
28
|
-
"\u215D": [5, 8],
|
|
29
|
-
"\u215E": [7, 8]
|
|
30
|
-
};
|
|
11
|
+
var VULGAR = "[\xBC-\xBE\u2150-\u215E]";
|
|
31
12
|
var ARTICLE = /^an?\s+(?=\D)/i;
|
|
32
|
-
var
|
|
33
|
-
function getPatterns() {
|
|
34
|
-
return patterns ?? (patterns = compilePatterns());
|
|
35
|
-
}
|
|
36
|
-
function compilePatterns() {
|
|
37
|
-
const vulgar = `[${Object.keys(VULGAR_FRACTIONS).join("")}]`;
|
|
38
|
-
const slash = "[/\u2044]";
|
|
39
|
-
const number = String.raw`(?:\d+\s+)?\d+\s*${slash}\s*\d+|(?:\d+\s*)?${vulgar}|\d+(?:\.\d+)?|\.\d+`;
|
|
40
|
-
return {
|
|
41
|
-
amount: new RegExp(
|
|
42
|
-
String.raw`^(${number})(?:\s*[-–—]\s*(${number})|\s+to\s+(${number}))?`,
|
|
43
|
-
"i"
|
|
44
|
-
),
|
|
45
|
-
asciiFraction: new RegExp(
|
|
46
|
-
String.raw`^(?:(\d+)\s+)?(\d+)\s*${slash}\s*(\d+)$`
|
|
47
|
-
)
|
|
48
|
-
};
|
|
49
|
-
}
|
|
13
|
+
var amountPattern;
|
|
50
14
|
function parseQuantity(text) {
|
|
51
15
|
const issues = [];
|
|
52
16
|
if (typeof text !== "string") {
|
|
@@ -63,7 +27,8 @@ function parseQuantity(text) {
|
|
|
63
27
|
}
|
|
64
28
|
function readQuantity(issues, value, path) {
|
|
65
29
|
if (typeof value === "string") {
|
|
66
|
-
|
|
30
|
+
const quantity = readQuantityText(issues, value, path);
|
|
31
|
+
return quantity && readTextAmount(issues, quantity.amount, path) ? quantity : void 0;
|
|
67
32
|
}
|
|
68
33
|
if (typeof value === "number") {
|
|
69
34
|
const amount = readPositive(issues, value, path);
|
|
@@ -71,7 +36,35 @@ function readQuantity(issues, value, path) {
|
|
|
71
36
|
}
|
|
72
37
|
const record = asRecord(value);
|
|
73
38
|
if (record) {
|
|
74
|
-
|
|
39
|
+
checkFields(issues, record, { amount: true, unit: true }, path);
|
|
40
|
+
const amount = readAmount(issues, record.amount, [...path, "amount"]);
|
|
41
|
+
const { unit } = record;
|
|
42
|
+
if (unit === void 0) {
|
|
43
|
+
return amount === void 0 ? void 0 : { amount };
|
|
44
|
+
}
|
|
45
|
+
if (typeof unit !== "string") {
|
|
46
|
+
return error(
|
|
47
|
+
issues,
|
|
48
|
+
"invalid_type",
|
|
49
|
+
[...path, "unit"],
|
|
50
|
+
`Expected a unit such as "g" or "cup", received ${describeValue(unit)}.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const id = findUnit(unit);
|
|
54
|
+
if (!id) {
|
|
55
|
+
const problem = unknownUnit(
|
|
56
|
+
unit,
|
|
57
|
+
amount === void 0 ? void 0 : typeof amount === "number" ? String(amount) : `{ amount: { min: ${amount.min}, max: ${amount.max} } }`
|
|
58
|
+
);
|
|
59
|
+
return error(
|
|
60
|
+
issues,
|
|
61
|
+
problem.code,
|
|
62
|
+
[...path, "unit"],
|
|
63
|
+
problem.message,
|
|
64
|
+
problem.suggestion
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return amount === void 0 ? void 0 : { amount, unit: id };
|
|
75
68
|
}
|
|
76
69
|
return error(
|
|
77
70
|
issues,
|
|
@@ -126,12 +119,18 @@ function matchAmount(text, allowZero) {
|
|
|
126
119
|
if (article) {
|
|
127
120
|
return { amount: 1, amountText: "1", rest: text.slice(article[0].length) };
|
|
128
121
|
}
|
|
129
|
-
|
|
122
|
+
if (!amountPattern) {
|
|
123
|
+
const number = String.raw`(?:\d+\s+)?\d+\s*[/⁄]\s*\d+|(?:\d+\s*)?${VULGAR}|\d+(?:\.\d+)?|\.\d+`;
|
|
124
|
+
amountPattern = new RegExp(
|
|
125
|
+
String.raw`^(${number})(?:(?:\s*[-–—]\s*|\s+to\s+)(${number}))?`,
|
|
126
|
+
"i"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const match = amountPattern.exec(text);
|
|
130
130
|
if (!match) {
|
|
131
131
|
return void 0;
|
|
132
132
|
}
|
|
133
|
-
const [matched, lowText = "",
|
|
134
|
-
const highText = highTextDash ?? highTextTo;
|
|
133
|
+
const [matched, lowText = "", highText] = match;
|
|
135
134
|
const low = toNumber(lowText);
|
|
136
135
|
const high = highText === void 0 ? low : toNumber(highText);
|
|
137
136
|
if (low === Infinity || high === Infinity) {
|
|
@@ -177,50 +176,25 @@ function unreadable(text) {
|
|
|
177
176
|
message: text === "" ? 'Expected a quantity such as "1 \xBD cups", received an empty string.' : `Couldn't read ${quote(text)}. Use a number or fraction, e.g. "1 \xBD cups".`
|
|
178
177
|
};
|
|
179
178
|
}
|
|
179
|
+
var fractions = /* @__PURE__ */ new Map();
|
|
180
180
|
function toNumber(text) {
|
|
181
|
-
const ascii =
|
|
181
|
+
const ascii = /^(?:(\d+)\s+)?(\d+)\s*[/⁄]\s*(\d+)$/.exec(text);
|
|
182
182
|
if (ascii) {
|
|
183
183
|
const [, whole = "0", numerator, denominator] = ascii;
|
|
184
184
|
return Number(whole) + Number(numerator) / (Number(denominator) || NaN);
|
|
185
185
|
}
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
const last = text.slice(-1);
|
|
187
|
+
if (last > "9") {
|
|
188
|
+
let fraction = fractions.get(last);
|
|
189
|
+
if (fraction === void 0) {
|
|
190
|
+
const [numerator, denominator] = last.normalize("NFKD").split("\u2044");
|
|
191
|
+
fraction = Number(numerator) / Number(denominator);
|
|
192
|
+
fractions.set(last, fraction);
|
|
193
|
+
}
|
|
194
|
+
return Number(text.slice(0, -1)) + fraction;
|
|
190
195
|
}
|
|
191
196
|
return Number(text);
|
|
192
197
|
}
|
|
193
|
-
function readQuantityObject(issues, record, path) {
|
|
194
|
-
checkFields(issues, record, { amount: true, unit: true }, path);
|
|
195
|
-
const amount = readAmount(issues, record.amount, [...path, "amount"]);
|
|
196
|
-
const { unit } = record;
|
|
197
|
-
if (unit === void 0) {
|
|
198
|
-
return amount === void 0 ? void 0 : { amount };
|
|
199
|
-
}
|
|
200
|
-
if (typeof unit !== "string") {
|
|
201
|
-
return error(
|
|
202
|
-
issues,
|
|
203
|
-
"invalid_type",
|
|
204
|
-
[...path, "unit"],
|
|
205
|
-
`Expected a unit such as "g" or "cup", received ${describeValue(unit)}.`
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
const id = findUnit(unit);
|
|
209
|
-
if (!id) {
|
|
210
|
-
const problem = unknownUnit(
|
|
211
|
-
unit,
|
|
212
|
-
amount === void 0 ? void 0 : typeof amount === "number" ? String(amount) : `{ amount: { min: ${amount.min}, max: ${amount.max} } }`
|
|
213
|
-
);
|
|
214
|
-
return error(
|
|
215
|
-
issues,
|
|
216
|
-
problem.code,
|
|
217
|
-
[...path, "unit"],
|
|
218
|
-
problem.message,
|
|
219
|
-
problem.suggestion
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
return amount === void 0 ? void 0 : { amount, unit: id };
|
|
223
|
-
}
|
|
224
198
|
function readAmount(issues, value, path) {
|
|
225
199
|
if (value === void 0) {
|
|
226
200
|
return error(
|
|
@@ -259,16 +233,19 @@ function readAmount(issues, value, path) {
|
|
|
259
233
|
);
|
|
260
234
|
}
|
|
261
235
|
function readPositive(issues, value, path) {
|
|
262
|
-
if (isPositive(value)) {
|
|
236
|
+
if (isPositive(value) && value >= 1e-300 && value <= 1e290) {
|
|
263
237
|
return value;
|
|
264
238
|
}
|
|
265
239
|
return error(
|
|
266
240
|
issues,
|
|
267
241
|
typeof value === "number" ? "invalid_amount" : "invalid_type",
|
|
268
242
|
path,
|
|
269
|
-
`Expected a positive number, received ${describeValue(value)}.`
|
|
243
|
+
isPositive(value) ? `Expected a number from 1e-300 to 1e290, received ${describeValue(value)}.` : `Expected a positive number, received ${describeValue(value)}.`
|
|
270
244
|
);
|
|
271
245
|
}
|
|
246
|
+
function readTextAmount(issues, amount, path) {
|
|
247
|
+
return (typeof amount === "number" ? [amount] : [amount.min, amount.max]).every((end) => readPositive(issues, end, path) !== void 0);
|
|
248
|
+
}
|
|
272
249
|
export {
|
|
273
250
|
matchAmount,
|
|
274
251
|
parseQuantity,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/quantity/parse.ts"],"sourcesContent":["import {\n describeValue,\n MAX_ECHOED,\n type ParseResult,\n quote,\n type RecipeIssue,\n type RecipeIssueCode,\n} from '../errors'\nimport type { Path } from '../issues'\nimport { findUnit, suggestUnit } from '../units/lookup'\nimport { units } from '../units/registry'\nimport { asRecord, checkFields, error, toResult } from '../validation'\nimport { isPositive } from './amount'\nimport type { Amount, Quantity } from './types'\n\nconst VULGAR_FRACTIONS: Readonly<Record<string, readonly [number, number]>> = {\n '¼': [1, 4],\n '½': [1, 2],\n '¾': [3, 4],\n '⅐': [1, 7],\n '⅑': [1, 9],\n '⅒': [1, 10],\n '⅓': [1, 3],\n '⅔': [2, 3],\n '⅕': [1, 5],\n '⅖': [2, 5],\n '⅗': [3, 5],\n '⅘': [4, 5],\n '⅙': [1, 6],\n '⅚': [5, 6],\n '⅛': [1, 8],\n '⅜': [3, 8],\n '⅝': [5, 8],\n '⅞': [7, 8],\n}\n\nconst ARTICLE = /^an?\\s+(?=\\D)/i\n\nlet patterns: ReturnType<typeof compilePatterns> | undefined\n\nfunction getPatterns() {\n return (patterns ??= compilePatterns())\n}\n\nfunction compilePatterns() {\n const vulgar = `[${Object.keys(VULGAR_FRACTIONS).join('')}]`\n const slash = '[/⁄]'\n // Alternatives are tried in order, so a fraction with an optional whole\n // number, as in \"1 1/2\", \"1/2\", \"1½\" or \"½\", comes before decimals and\n // integers.\n const number = String.raw`(?:\\d+\\s+)?\\d+\\s*${slash}\\s*\\d+|(?:\\d+\\s*)?${vulgar}|\\d+(?:\\.\\d+)?|\\.\\d+`\n\n return {\n amount: new RegExp(\n String.raw`^(${number})(?:\\s*[-–—]\\s*(${number})|\\s+to\\s+(${number}))?`,\n 'i',\n ),\n asciiFraction: new RegExp(\n String.raw`^(?:(\\d+)\\s+)?(\\d+)\\s*${slash}\\s*(\\d+)$`,\n ),\n }\n}\n\nexport type Problem = {\n code: RecipeIssueCode\n message: string\n suggestion?: string\n}\n\n/** An amount read from the start of text, as written, and the text after it. */\ntype AmountMatch = { amount: Amount; amountText: string; rest: string }\n\n/**\n * Parses quantity text, such as `'1 ½ cups'`, `'2-3 tbsp'`, `'a pinch'`, or\n * `'3'` for a bare count. Never throws.\n *\n * Accepts whole numbers, decimals, fractions (`1/2`, `½`, also with the\n * fraction slash `⁄`), mixed numbers (`1 1/2`, `1½`) and ranges (`2-3`, `2–3`,\n * `2 to 3`), each followed by an optional unit alias, and `a` or `an` for 1\n * before a unit alias, as in `'a pinch'`.\n *\n * @example\n * parseQuantity('1 ½ cups') // { success: true, data: { amount: 1.5, unit: 'cup' }, warnings: [] }\n *\n * const result = parseQuantity('2 cupz')\n * if (!result.success) {\n * result.error.message // '\"2 cupz\" has 1 problem:\\n ✖ (root) Unknown unit \"cupz\". Did you mean \"cups\"?\\nSee https://recipe-book.siraj.page/errors/invalid_quantity'\n * }\n */\nexport function parseQuantity(text: string): ParseResult<Quantity> {\n const issues: RecipeIssue[] = []\n\n if (typeof text !== 'string') {\n error(\n issues,\n 'invalid_type',\n [],\n `Expected quantity text such as \"1 ½ cups\", received ${describeValue(text)}.`,\n )\n return toResult<Quantity>('invalid_quantity', undefined, issues)\n }\n\n const quantity = readQuantityText(issues, text, [])\n return toResult('invalid_quantity', quantity, issues, quote(text))\n}\n\n/** Reads a quantity in any input form: text, a number, or `{ amount, unit }`. */\nexport function readQuantity(\n issues: RecipeIssue[],\n value: unknown,\n path: Path,\n): Quantity | undefined {\n if (typeof value === 'string') {\n return readQuantityText(issues, value, path)\n }\n\n if (typeof value === 'number') {\n const amount = readPositive(issues, value, path)\n return amount === undefined ? undefined : { amount }\n }\n\n const record = asRecord(value)\n if (record) {\n return readQuantityObject(issues, record, path)\n }\n\n return error(\n issues,\n 'invalid_type',\n path,\n `Expected a quantity such as \"2 cups\", 2, or { amount: 2, unit: \"cup\" }, received ${describeValue(value)}.`,\n )\n}\n\nfunction readQuantityText(\n issues: RecipeIssue[],\n text: string,\n path: Path,\n): Quantity | undefined {\n const result = parseText(text.replace(/\\s+/g, ' ').trim())\n\n if ('code' in result) {\n return error(issues, result.code, path, result.message, result.suggestion)\n }\n\n return result\n}\n\nfunction parseText(text: string): Quantity | Problem {\n const match = matchAmount(text)\n\n if (!match) {\n const unit = findUnit(text)\n return unit\n ? {\n code: 'invalid_quantity',\n message: `${quote(text)} has no amount. Write a full quantity, e.g. \"1 ${units[unit].labels.short.one}\".`,\n }\n : unreadable(text)\n }\n\n if ('code' in match) {\n return match\n }\n\n const { amount, amountText, rest: unitText } = match\n\n if (unitText === '') {\n return { amount }\n }\n\n if (/\\d/.test(unitText) || !/\\p{L}/u.test(unitText)) {\n return unreadable(text)\n }\n\n const unit = findUnit(unitText)\n if (unit) {\n return { amount, unit }\n }\n\n return unknownUnit(\n unitText,\n amountText.length <= MAX_ECHOED && /^\\d+(?:\\.\\d+)?$/.test(amountText)\n ? amountText\n : quote(amountText),\n )\n}\n\n/**\n * Reads the amount that starts `text`, which is trimmed with single spaces: a\n * number, fraction, mixed number, range, or \"a\" or \"an\" for 1. A comma between\n * digits anywhere in the text, as in \"1,5\", is a problem. Otherwise returns\n * `undefined` when the text doesn't start with an amount, and a problem when\n * the amount is too large to read, isn't greater than 0 (0 is allowed with\n * `allowZero`), or is a range whose low end is above its high end.\n */\nexport function matchAmount(\n text: string,\n allowZero?: boolean,\n): AmountMatch | Problem | undefined {\n // \"1,5\" is a decimal in much of Europe and \"1,500\" a thousands separator in\n // the US, so neither is guessed.\n if (/\\d,\\d/.test(text)) {\n return {\n code: 'invalid_quantity',\n message: `Couldn't read ${quote(text)}. Write decimals with a period, e.g. \"1.5\", and leave out thousands separators.`,\n }\n }\n\n const article = ARTICLE.exec(text)\n if (article) {\n return { amount: 1, amountText: '1', rest: text.slice(article[0].length) }\n }\n\n const match = getPatterns().amount.exec(text)\n if (!match) {\n return undefined\n }\n\n const [matched, lowText = '', highTextDash, highTextTo] = match\n const highText = highTextDash ?? highTextTo\n const low = toNumber(lowText)\n const high = highText === undefined ? low : toNumber(highText)\n\n if (low === Infinity || high === Infinity) {\n return {\n code: 'invalid_amount',\n message: `${quote(text)} has an amount too large to read. Check it for extra digits.`,\n }\n }\n\n if (\n !(isPositive(low) || (allowZero && low === 0)) ||\n !(isPositive(high) || (allowZero && high === 0))\n ) {\n return {\n code: 'invalid_amount',\n message: `Expected a positive amount, received ${quote(text)}.`,\n }\n }\n\n if (low > high) {\n const mixedHint =\n /^\\d+$/.test(lowText) && high < 1\n ? ` To write a mixed number, use ${quote(`${lowText} ${highText}`)}.`\n : ''\n return {\n code: 'invalid_range',\n message: `Range minimum (${truncateNumber(lowText)}) must be less than maximum (${truncateNumber(highText!)}).${mixedHint}`,\n }\n }\n\n return {\n amount: low === high ? low : { min: low, max: high },\n amountText: matched,\n rest: text.slice(matched.length).trim(),\n }\n}\n\n/**\n * The `unknown_unit` problem. `quantity` is the line's quantity without the\n * unit, as it would be written in `RecipeInput`, for the \"move the word into\n * the name\" fix.\n */\nfunction unknownUnit(unitText: string, quantity: string | undefined): Problem {\n const suggestion = suggestUnit(unitText)\n const move =\n quantity === undefined\n ? 'move the word into the name'\n : `move the word into the name: { name: ${quote(unitText)}, quantity: ${quantity} }`\n\n return {\n code: 'unknown_unit',\n message: suggestion\n ? `Unknown unit ${quote(unitText)}. Did you mean \"${suggestion}\"?`\n : `Unknown unit ${quote(unitText)}. Use a known unit, or ${move}.`,\n ...(suggestion && { suggestion }),\n }\n}\n\nfunction truncateNumber(text: string): string {\n return text.length > 20 ? `${text.slice(0, 19)}…` : text\n}\n\nfunction unreadable(text: string): Problem {\n return {\n code: 'invalid_quantity',\n message:\n text === ''\n ? 'Expected a quantity such as \"1 ½ cups\", received an empty string.'\n : `Couldn't read ${quote(text)}. Use a number or fraction, e.g. \"1 ½ cups\".`,\n }\n}\n\nfunction toNumber(text: string): number {\n const ascii = getPatterns().asciiFraction.exec(text)\n if (ascii) {\n const [, whole = '0', numerator, denominator] = ascii\n // A zero denominator reads as NaN, not Infinity, so \"1/0\" isn't too large.\n return Number(whole) + Number(numerator) / (Number(denominator) || NaN)\n }\n\n const vulgar = VULGAR_FRACTIONS[text.slice(-1)]\n if (vulgar) {\n const [numerator, denominator] = vulgar\n // Before a glyph is a whole number and any spaces, or nothing, which\n // Number reads as 0.\n return Number(text.slice(0, -1)) + numerator / denominator\n }\n\n return Number(text)\n}\n\nfunction readQuantityObject(\n issues: RecipeIssue[],\n record: Record<string, unknown>,\n path: Path,\n): Quantity | undefined {\n checkFields(issues, record, { amount: true, unit: true }, path)\n\n const amount = readAmount(issues, record.amount, [...path, 'amount'])\n const { unit } = record\n\n if (unit === undefined) {\n return amount === undefined ? undefined : { amount }\n }\n\n if (typeof unit !== 'string') {\n return error(\n issues,\n 'invalid_type',\n [...path, 'unit'],\n `Expected a unit such as \"g\" or \"cup\", received ${describeValue(unit)}.`,\n )\n }\n\n const id = findUnit(unit)\n\n if (!id) {\n const problem = unknownUnit(\n unit,\n amount === undefined\n ? undefined\n : typeof amount === 'number'\n ? String(amount)\n : `{ amount: { min: ${amount.min}, max: ${amount.max} } }`,\n )\n return error(\n issues,\n problem.code,\n [...path, 'unit'],\n problem.message,\n problem.suggestion,\n )\n }\n\n return amount === undefined ? undefined : { amount, unit: id }\n}\n\nfunction readAmount(\n issues: RecipeIssue[],\n value: unknown,\n path: Path,\n): Amount | undefined {\n if (value === undefined) {\n return error(\n issues,\n 'missing_field',\n path,\n 'Add an amount, e.g. { amount: 2, unit: \"cup\" }.',\n )\n }\n\n if (typeof value === 'number') {\n return readPositive(issues, value, path)\n }\n\n const range = asRecord(value)\n if (range && 'min' in range && 'max' in range) {\n checkFields(issues, range, { min: true, max: true }, path)\n\n const min = readPositive(issues, range.min, [...path, 'min'])\n const max = readPositive(issues, range.max, [...path, 'max'])\n\n if (min === undefined || max === undefined) {\n return undefined\n }\n\n if (min > max) {\n return error(\n issues,\n 'invalid_range',\n path,\n `Range minimum (${min}) must be less than maximum (${max}).`,\n )\n }\n\n return min === max ? min : { min, max }\n }\n\n return error(\n issues,\n 'invalid_type',\n path,\n `Expected a number or { min, max }, received ${describeValue(value)}.`,\n )\n}\n\nexport function readPositive(\n issues: RecipeIssue[],\n value: unknown,\n path: Path,\n): number | undefined {\n if (isPositive(value)) {\n return value\n }\n\n return error(\n issues,\n typeof value === 'number' ? 'invalid_amount' : 'invalid_type',\n path,\n `Expected a positive number, received ${describeValue(value)}.`,\n )\n}\n"],"mappings":"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","names":["unit"]}
|
|
1
|
+
{"version":3,"sources":["../../src/quantity/parse.ts"],"sourcesContent":["import {\n describeValue,\n MAX_ECHOED,\n type ParseResult,\n quote,\n type RecipeIssue,\n type RecipeIssueCode,\n} from '../errors'\nimport type { Path } from '../issues'\nimport { findUnit, suggestUnit } from '../units/lookup'\nimport { units } from '../units/registry'\nimport { asRecord, checkFields, error, toResult } from '../validation'\nimport { isPositive } from './amount'\nimport type { Amount, Quantity } from './types'\n\n// ¼, ½, ¾ and ⅐ to ⅞, which decompose to their fractions, such as '½' to\n// '1⁄2'. ⅟ has no denominator, and ↉ is zero.\nconst VULGAR = '[¼-¾⅐-⅞]'\n\nconst ARTICLE = /^an?\\s+(?=\\D)/i\n\nlet amountPattern: RegExp | undefined\n\nexport type Problem = {\n code: RecipeIssueCode\n message: string\n suggestion?: string\n}\n\n/**\n * Parses quantity text, such as `'1 ½ cups'`, `'2-3 tbsp'`, `'a pinch'`, or\n * `'3'` for a bare count. Never throws.\n *\n * Accepts whole numbers, decimals, fractions (`1/2`, `½`, also with the\n * fraction slash `⁄`), mixed numbers (`1 1/2`, `1½`) and ranges (`2-3`, `2–3`,\n * `2 to 3`), each followed by an optional unit alias, and `a` or `an` for 1\n * before a unit alias, as in `'a pinch'`.\n *\n * @example\n * parseQuantity('1 ½ cups') // { success: true, data: { amount: 1.5, unit: 'cup' }, warnings: [] }\n *\n * const result = parseQuantity('2 cupz')\n * if (!result.success) {\n * result.error.message // '\"2 cupz\" has 1 problem:\\n ✖ (root) Unknown unit \"cupz\". Did you mean \"cups\"?\\nSee https://recipe-book.siraj.page/errors/invalid_quantity/'\n * }\n */\nexport function parseQuantity(text: string): ParseResult<Quantity> {\n const issues: RecipeIssue[] = []\n\n if (typeof text !== 'string') {\n error(\n issues,\n 'invalid_type',\n [],\n `Expected quantity text such as \"1 ½ cups\", received ${describeValue(text)}.`,\n )\n return toResult<Quantity>('invalid_quantity', undefined, issues)\n }\n\n const quantity = readQuantityText(issues, text, [])\n return toResult('invalid_quantity', quantity, issues, quote(text))\n}\n\n/** Reads a quantity in any input form: text, a number, or `{ amount, unit }`. */\nexport function readQuantity(\n issues: RecipeIssue[],\n value: unknown,\n path: Path,\n): Quantity | undefined {\n if (typeof value === 'string') {\n const quantity = readQuantityText(issues, value, path)\n return quantity && readTextAmount(issues, quantity.amount, path)\n ? quantity\n : undefined\n }\n\n if (typeof value === 'number') {\n const amount = readPositive(issues, value, path)\n return amount === undefined ? undefined : { amount }\n }\n\n const record = asRecord(value)\n if (record) {\n checkFields(issues, record, { amount: true, unit: true }, path)\n\n const amount = readAmount(issues, record.amount, [...path, 'amount'])\n const { unit } = record\n\n if (unit === undefined) {\n return amount === undefined ? undefined : { amount }\n }\n\n if (typeof unit !== 'string') {\n return error(\n issues,\n 'invalid_type',\n [...path, 'unit'],\n `Expected a unit such as \"g\" or \"cup\", received ${describeValue(unit)}.`,\n )\n }\n\n const id = findUnit(unit)\n\n if (!id) {\n const problem = unknownUnit(\n unit,\n amount === undefined\n ? undefined\n : typeof amount === 'number'\n ? String(amount)\n : `{ amount: { min: ${amount.min}, max: ${amount.max} } }`,\n )\n return error(\n issues,\n problem.code,\n [...path, 'unit'],\n problem.message,\n problem.suggestion,\n )\n }\n\n return amount === undefined ? undefined : { amount, unit: id }\n }\n\n return error(\n issues,\n 'invalid_type',\n path,\n `Expected a quantity such as \"2 cups\", 2, or { amount: 2, unit: \"cup\" }, received ${describeValue(value)}.`,\n )\n}\n\nfunction readQuantityText(\n issues: RecipeIssue[],\n text: string,\n path: Path,\n): Quantity | undefined {\n const result = parseText(text.replace(/\\s+/g, ' ').trim())\n\n if ('code' in result) {\n return error(issues, result.code, path, result.message, result.suggestion)\n }\n\n return result\n}\n\nfunction parseText(text: string): Quantity | Problem {\n const match = matchAmount(text)\n\n if (!match) {\n const unit = findUnit(text)\n return unit\n ? {\n code: 'invalid_quantity',\n message: `${quote(text)} has no amount. Write a full quantity, e.g. \"1 ${units[unit].labels.short.one}\".`,\n }\n : unreadable(text)\n }\n\n if ('code' in match) {\n return match\n }\n\n const { amount, amountText, rest: unitText } = match\n\n if (unitText === '') {\n return { amount }\n }\n\n if (/\\d/.test(unitText) || !/\\p{L}/u.test(unitText)) {\n return unreadable(text)\n }\n\n const unit = findUnit(unitText)\n if (unit) {\n return { amount, unit }\n }\n\n return unknownUnit(\n unitText,\n amountText.length <= MAX_ECHOED && /^\\d+(?:\\.\\d+)?$/.test(amountText)\n ? amountText\n : quote(amountText),\n )\n}\n\n/**\n * Reads the amount that starts `text`, which is trimmed with single spaces: a\n * number, fraction, mixed number, range, or \"a\" or \"an\" for 1. A comma between\n * digits anywhere in the text, as in \"1,5\", is a problem. Otherwise returns\n * `undefined` when the text doesn't start with an amount, and a problem when\n * the amount is too large to read, isn't greater than 0 (0 is allowed with\n * `allowZero`), or is a range whose low end is above its high end. When it\n * reads one, it returns the amount, its text as written, and the text after it.\n */\nexport function matchAmount(\n text: string,\n allowZero?: boolean,\n): { amount: Amount; amountText: string; rest: string } | Problem | undefined {\n // \"1,5\" is a decimal in much of Europe and \"1,500\" a thousands separator in\n // the US, so neither is guessed.\n if (/\\d,\\d/.test(text)) {\n return {\n code: 'invalid_quantity',\n message: `Couldn't read ${quote(text)}. Write decimals with a period, e.g. \"1.5\", and leave out thousands separators.`,\n }\n }\n\n const article = ARTICLE.exec(text)\n if (article) {\n return { amount: 1, amountText: '1', rest: text.slice(article[0].length) }\n }\n\n if (!amountPattern) {\n // Alternatives are tried in order, so a fraction with an optional whole\n // number, as in \"1 1/2\", \"1/2\", \"1½\" or \"½\", comes before decimals and\n // integers.\n const number = String.raw`(?:\\d+\\s+)?\\d+\\s*[/⁄]\\s*\\d+|(?:\\d+\\s*)?${VULGAR}|\\d+(?:\\.\\d+)?|\\.\\d+`\n amountPattern = new RegExp(\n String.raw`^(${number})(?:(?:\\s*[-–—]\\s*|\\s+to\\s+)(${number}))?`,\n 'i',\n )\n }\n\n const match = amountPattern.exec(text)\n if (!match) {\n return undefined\n }\n\n const [matched, lowText = '', highText] = match\n const low = toNumber(lowText)\n const high = highText === undefined ? low : toNumber(highText)\n\n if (low === Infinity || high === Infinity) {\n return {\n code: 'invalid_amount',\n message: `${quote(text)} has an amount too large to read. Check it for extra digits.`,\n }\n }\n\n if (\n !(isPositive(low) || (allowZero && low === 0)) ||\n !(isPositive(high) || (allowZero && high === 0))\n ) {\n return {\n code: 'invalid_amount',\n message: `Expected a positive amount, received ${quote(text)}.`,\n }\n }\n\n if (low > high) {\n const mixedHint =\n /^\\d+$/.test(lowText) && high < 1\n ? ` To write a mixed number, use ${quote(`${lowText} ${highText}`)}.`\n : ''\n return {\n code: 'invalid_range',\n message: `Range minimum (${truncateNumber(lowText)}) must be less than maximum (${truncateNumber(highText!)}).${mixedHint}`,\n }\n }\n\n return {\n amount: low === high ? low : { min: low, max: high },\n amountText: matched,\n rest: text.slice(matched.length).trim(),\n }\n}\n\n/**\n * The `unknown_unit` problem. `quantity` is the line's quantity without the\n * unit, as it would be written in `RecipeInput`, for the \"move the word into\n * the name\" fix.\n */\nfunction unknownUnit(unitText: string, quantity: string | undefined): Problem {\n const suggestion = suggestUnit(unitText)\n const move =\n quantity === undefined\n ? 'move the word into the name'\n : `move the word into the name: { name: ${quote(unitText)}, quantity: ${quantity} }`\n\n return {\n code: 'unknown_unit',\n message: suggestion\n ? `Unknown unit ${quote(unitText)}. Did you mean \"${suggestion}\"?`\n : `Unknown unit ${quote(unitText)}. Use a known unit, or ${move}.`,\n ...(suggestion && { suggestion }),\n }\n}\n\nfunction truncateNumber(text: string): string {\n return text.length > 20 ? `${text.slice(0, 19)}…` : text\n}\n\nfunction unreadable(text: string): Problem {\n return {\n code: 'invalid_quantity',\n message:\n text === ''\n ? 'Expected a quantity such as \"1 ½ cups\", received an empty string.'\n : `Couldn't read ${quote(text)}. Use a number or fraction, e.g. \"1 ½ cups\".`,\n }\n}\n\n// Decomposing a glyph is slow, so each glyph's value is read once.\nconst fractions = new Map<string, number>()\n\nfunction toNumber(text: string): number {\n // The `[/⁄]` must match the one in `amountPattern`.\n const ascii = /^(?:(\\d+)\\s+)?(\\d+)\\s*[/⁄]\\s*(\\d+)$/.exec(text)\n if (ascii) {\n const [, whole = '0', numerator, denominator] = ascii\n // A zero denominator reads as NaN, not Infinity, so \"1/0\" isn't too large.\n return Number(whole) + Number(numerator) / (Number(denominator) || NaN)\n }\n\n // Amounts end in a digit or a glyph.\n const last = text.slice(-1)\n if (last > '9') {\n let fraction = fractions.get(last)\n if (fraction === undefined) {\n const [numerator, denominator] = last.normalize('NFKD').split('⁄')\n fraction = Number(numerator) / Number(denominator)\n fractions.set(last, fraction)\n }\n // Before a glyph is a whole number and any spaces, or nothing, which\n // Number reads as 0.\n return Number(text.slice(0, -1)) + fraction\n }\n\n return Number(text)\n}\n\nfunction readAmount(\n issues: RecipeIssue[],\n value: unknown,\n path: Path,\n): Amount | undefined {\n if (value === undefined) {\n return error(\n issues,\n 'missing_field',\n path,\n 'Add an amount, e.g. { amount: 2, unit: \"cup\" }.',\n )\n }\n\n if (typeof value === 'number') {\n return readPositive(issues, value, path)\n }\n\n const range = asRecord(value)\n if (range && 'min' in range && 'max' in range) {\n checkFields(issues, range, { min: true, max: true }, path)\n\n const min = readPositive(issues, range.min, [...path, 'min'])\n const max = readPositive(issues, range.max, [...path, 'max'])\n\n if (min === undefined || max === undefined) {\n return undefined\n }\n\n if (min > max) {\n return error(\n issues,\n 'invalid_range',\n path,\n `Range minimum (${min}) must be less than maximum (${max}).`,\n )\n }\n\n return min === max ? min : { min, max }\n }\n\n return error(\n issues,\n 'invalid_type',\n path,\n `Expected a number or { min, max }, received ${describeValue(value)}.`,\n )\n}\n\n/**\n * Reads a number from 1e-300 to 1e290. Scaling by 1e-6 to 1e6 and converting\n * between any two units in the registry keeps an amount in that range finite\n * and greater than 0.\n */\nexport function readPositive(\n issues: RecipeIssue[],\n value: unknown,\n path: Path,\n): number | undefined {\n if (isPositive(value) && value >= 1e-300 && value <= 1e290) {\n return value\n }\n\n return error(\n issues,\n typeof value === 'number' ? 'invalid_amount' : 'invalid_type',\n path,\n isPositive(value)\n ? `Expected a number from 1e-300 to 1e290, received ${describeValue(value)}.`\n : `Expected a positive number, received ${describeValue(value)}.`,\n )\n}\n\n/** Checks each end of an amount read from text with `readPositive`. */\nfunction readTextAmount(\n issues: RecipeIssue[],\n amount: Amount,\n path: Path,\n): boolean {\n return (\n typeof amount === 'number' ? [amount] : [amount.min, amount.max]\n ).every((end) => readPositive(issues, end, path) !== undefined)\n}\n"],"mappings":"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","names":["unit"]}
|
package/dist/quantity/scale.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import {
|
|
3
3
|
checkFactor,
|
|
4
4
|
checkQuantity,
|
|
5
|
+
checkResult,
|
|
5
6
|
mapAmount
|
|
6
7
|
} from "./amount.js";
|
|
7
8
|
function scaleQuantity(quantity, factor) {
|
|
8
|
-
checkQuantity("scaleQuantity", quantity);
|
|
9
|
+
const amount = checkQuantity("scaleQuantity", quantity);
|
|
9
10
|
checkFactor("scaleQuantity", "factor", factor);
|
|
10
|
-
|
|
11
|
+
const result = multiply({ amount, unit: quantity.unit }, factor);
|
|
12
|
+
checkResult("scaleQuantity", result.amount);
|
|
13
|
+
return result;
|
|
11
14
|
}
|
|
12
15
|
function multiply(quantity, factor) {
|
|
13
16
|
const amount = mapAmount(quantity.amount, (value) => value * factor);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/quantity/scale.ts"],"sourcesContent":["import type { UnitId } from '../units/registry'\nimport {\n checkFactor,\n checkQuantity,\n mapAmount,\n type SameAmount,\n} from './amount'\nimport type { Amount, Quantity } from './types'\n\n/**\n * Multiplies a quantity by `factor`. Both ends of a range scale, the unit is\n * kept, and nothing is rounded.\n *\n * @throws {RecipeBookError} `invalid_argument` when `factor` is not a finite\n * number greater than 0, `quantity` isn't a quantity object, the amount is\n * not a finite number greater than 0 or a range of them, a range's `min`\n * isn't less than its `max`, or the quantity has `max` set, as a `Servings`\n * or `Yield` has. `resolveRecipe` scales those. The unit isn't checked.\n *\n * @example\n * scaleQuantity({ amount: 1.5, unit: 'cup' }, 2) // { amount: 3, unit: 'cup' }\n * scaleQuantity({ amount: { min: 2, max: 3 }, unit: 'tbsp' }, 2) // { amount: { min: 4, max: 6 }, unit: 'tbsp' }\n */\nexport function scaleQuantity<A extends Amount>(\n quantity: Quantity & { amount: A },\n factor: number,\n): { amount: SameAmount<A>; unit?: UnitId | undefined } {\n checkQuantity('scaleQuantity', quantity)\n checkFactor('scaleQuantity', 'factor', factor)\n\n
|
|
1
|
+
{"version":3,"sources":["../../src/quantity/scale.ts"],"sourcesContent":["import type { UnitId } from '../units/registry'\nimport {\n checkFactor,\n checkQuantity,\n checkResult,\n mapAmount,\n type SameAmount,\n} from './amount'\nimport type { Amount, Quantity } from './types'\n\n/**\n * Multiplies a quantity by `factor`. Both ends of a range scale, the unit is\n * kept, and nothing is rounded.\n *\n * An error thrown by a getter or proxy in the input passes through.\n *\n * @throws {RecipeBookError} `invalid_argument` when `factor` is not a finite\n * number greater than 0, `quantity` isn't a quantity object, the amount is\n * not a finite number greater than 0 or a range of them, a range's `min`\n * isn't less than its `max`, or the quantity has `max` set, as a `Servings`\n * or `Yield` has. `resolveRecipe` scales those. The unit isn't checked.\n * Also thrown when an end times `factor` overflows to `Infinity` or\n * underflows to 0: past about 1.8e308, or below about 5e-324.\n *\n * @example\n * scaleQuantity({ amount: 1.5, unit: 'cup' }, 2) // { amount: 3, unit: 'cup' }\n * scaleQuantity({ amount: { min: 2, max: 3 }, unit: 'tbsp' }, 2) // { amount: { min: 4, max: 6 }, unit: 'tbsp' }\n */\nexport function scaleQuantity<A extends Amount>(\n quantity: Quantity & { amount: A },\n factor: number,\n): { amount: SameAmount<A>; unit?: UnitId | undefined } {\n const amount = checkQuantity('scaleQuantity', quantity) as A\n checkFactor('scaleQuantity', 'factor', factor)\n\n const result = multiply({ amount, unit: quantity.unit }, factor)\n checkResult('scaleQuantity', result.amount)\n return result\n}\n\n/** {@link scaleQuantity} without argument checks. */\nexport function multiply<A extends Amount>(\n quantity: Quantity & { amount: A },\n factor: number,\n): { amount: SameAmount<A>; unit?: UnitId | undefined } {\n const amount = mapAmount(quantity.amount, (value) => value * factor)\n return quantity.unit === undefined\n ? { amount }\n : { amount, unit: quantity.unit }\n}\n"],"mappings":";AACA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAqBA,SAAS,cACd,UACA,QACsD;AACtD,QAAM,SAAS,cAAc,iBAAiB,QAAQ;AACtD,cAAY,iBAAiB,UAAU,MAAM;AAE7C,QAAM,SAAS,SAAS,EAAE,QAAQ,MAAM,SAAS,KAAK,GAAG,MAAM;AAC/D,cAAY,iBAAiB,OAAO,MAAM;AAC1C,SAAO;AACT;AAGO,SAAS,SACd,UACA,QACsD;AACtD,QAAM,SAAS,UAAU,SAAS,QAAQ,CAAC,UAAU,QAAQ,MAAM;AACnE,SAAO,SAAS,SAAS,SACrB,EAAE,OAAO,IACT,EAAE,QAAQ,MAAM,SAAS,KAAK;AACpC;","names":[]}
|
package/dist/recipe/ids.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/recipe/ids.ts"],"sourcesContent":["import { quote, type RecipeIssue } from '../errors'\nimport { issue, type Path } from '../issues'\n\n/**\n * An id to assign. `position` is where the item's issues start in the issue\n * list, so a `duplicate_id` warning can be placed in input order.\n */\nexport type IdRequest = {\n authored: string | undefined\n derived: string\n path: Path\n position: number\n}\n\nexport type PendingIssue = { position: number; issue: RecipeIssue }\n\n/**\n * Lowercase words joined by hyphens,
|
|
1
|
+
{"version":3,"sources":["../../src/recipe/ids.ts"],"sourcesContent":["import { quote, type RecipeIssue } from '../errors'\nimport { issue, type Path } from '../issues'\n\n/**\n * An id to assign. `position` is where the item's issues start in the issue\n * list, so a `duplicate_id` warning can be placed in input order.\n */\nexport type IdRequest = {\n authored: string | undefined\n derived: string\n path: Path\n position: number\n}\n\nexport type PendingIssue = { position: number; issue: RecipeIssue }\n\n/**\n * Lowercase words joined by hyphens, with compatibility characters spelled out\n * (`½` → `1-2`, `fig` → `fig`) and accents dropped from Latin letters:\n * \"Crème fraîche\" → \"creme-fraiche\", \"わさび\" → \"わさび\".\n */\nexport function slugify(text: string): string {\n return (\n text\n .normalize('NFKD')\n // Marks in other scripts are part of the letter: dropping them turns わさび into わさひ.\n .replace(/(\\p{Script=Latin})\\p{M}+/gu, '$1')\n // Lowercase before NFC: some Greek capitals with marks, such as Ϊ́, have no precomposed form but their lowercase does.\n .toLowerCase()\n .normalize('NFC')\n // A mark with no letter before it, such as the variation selector in ❤️, would be invisible.\n .replace(/(^|[^\\p{L}\\p{M}])\\p{M}+/gu, '$1')\n .replace(/[^\\p{L}\\p{M}\\p{N}]+/gu, '-')\n .replace(/^-+|-+$/g, '')\n )\n}\n\n/**\n * Gives each request a unique id. Authored ids are claimed first, so a derived\n * id never takes one; a repeated authored id is renamed with a warning. Taken\n * ids get the next free suffix: `egg`, `egg-2`, `egg-3`.\n */\nexport function assignIds(\n requests: readonly IdRequest[],\n pending: PendingIssue[],\n): string[] {\n const used = new Set<string>()\n const nextSuffix = new Map<string, number>()\n const ids = requests.map(({ authored }) => {\n if (authored === undefined || used.has(authored)) {\n return undefined\n }\n used.add(authored)\n return authored\n })\n\n return requests.map((request, i) => {\n const claimed = ids[i]\n if (claimed !== undefined) {\n return claimed\n }\n\n const base = request.authored ?? request.derived\n let id = base\n if (used.has(id)) {\n // Resuming from the last suffix keeps many repeats of one id linear.\n let n = nextSuffix.get(base) ?? 2\n while (used.has((id = `${base}-${n}`))) {\n n++\n }\n nextSuffix.set(base, n + 1)\n }\n used.add(id)\n\n if (request.authored !== undefined) {\n pending.push({\n position: request.position,\n issue: issue(\n 'warning',\n 'duplicate_id',\n [...request.path, 'id'],\n `${quote(request.authored)} is already used; renamed to ${quote(id)}.`,\n ),\n })\n }\n\n return id\n })\n}\n\n/** Inserts pending issues at their positions, keeping input order. */\nexport function insertPending(\n issues: RecipeIssue[],\n pending: readonly PendingIssue[],\n): void {\n if (pending.length === 0) {\n return\n }\n\n const sorted = [...pending].sort((a, b) => a.position - b.position)\n const original = issues.splice(0)\n let next = 0\n for (const { position, issue } of sorted) {\n while (next < position) {\n issues.push(original[next++]!)\n }\n issues.push(issue)\n }\n while (next < original.length) {\n issues.push(original[next++]!)\n }\n}\n"],"mappings":";AAAA,SAAS,aAA+B;AACxC,SAAS,aAAwB;AAoB1B,SAAS,QAAQ,MAAsB;AAC5C,SACE,KACG,UAAU,MAAM,EAEhB,QAAQ,8BAA8B,IAAI,EAE1C,YAAY,EACZ,UAAU,KAAK,EAEf,QAAQ,6BAA6B,IAAI,EACzC,QAAQ,yBAAyB,GAAG,EACpC,QAAQ,YAAY,EAAE;AAE7B;AAOO,SAAS,UACd,UACA,SACU;AACV,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,aAAa,oBAAI,IAAoB;AAC3C,QAAM,MAAM,SAAS,IAAI,CAAC,EAAE,SAAS,MAAM;AACzC,QAAI,aAAa,UAAa,KAAK,IAAI,QAAQ,GAAG;AAChD,aAAO;AAAA,IACT;AACA,SAAK,IAAI,QAAQ;AACjB,WAAO;AAAA,EACT,CAAC;AAED,SAAO,SAAS,IAAI,CAAC,SAAS,MAAM;AAClC,UAAM,UAAU,IAAI,CAAC;AACrB,QAAI,YAAY,QAAW;AACzB,aAAO;AAAA,IACT;AAEA,UAAM,OAAO,QAAQ,YAAY,QAAQ;AACzC,QAAI,KAAK;AACT,QAAI,KAAK,IAAI,EAAE,GAAG;AAEhB,UAAI,IAAI,WAAW,IAAI,IAAI,KAAK;AAChC,aAAO,KAAK,IAAK,KAAK,GAAG,IAAI,IAAI,CAAC,EAAG,GAAG;AACtC;AAAA,MACF;AACA,iBAAW,IAAI,MAAM,IAAI,CAAC;AAAA,IAC5B;AACA,SAAK,IAAI,EAAE;AAEX,QAAI,QAAQ,aAAa,QAAW;AAClC,cAAQ,KAAK;AAAA,QACX,UAAU,QAAQ;AAAA,QAClB,OAAO;AAAA,UACL;AAAA,UACA;AAAA,UACA,CAAC,GAAG,QAAQ,MAAM,IAAI;AAAA,UACtB,GAAG,MAAM,QAAQ,QAAQ,CAAC,gCAAgC,MAAM,EAAE,CAAC;AAAA,QACrE;AAAA,MACF,CAAC;AAAA,IACH;AAEA,WAAO;AAAA,EACT,CAAC;AACH;AAGO,SAAS,cACd,QACA,SACM;AACN,MAAI,QAAQ,WAAW,GAAG;AACxB;AAAA,EACF;AAEA,QAAM,SAAS,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ;AAClE,QAAM,WAAW,OAAO,OAAO,CAAC;AAChC,MAAI,OAAO;AACX,aAAW,EAAE,UAAU,OAAAA,OAAM,KAAK,QAAQ;AACxC,WAAO,OAAO,UAAU;AACtB,aAAO,KAAK,SAAS,MAAM,CAAE;AAAA,IAC/B;AACA,WAAO,KAAKA,MAAK;AAAA,EACnB;AACA,SAAO,OAAO,SAAS,QAAQ;AAC7B,WAAO,KAAK,SAAS,MAAM,CAAE;AAAA,EAC/B;AACF;","names":["issue"]}
|
package/dist/recipe/parse.js
CHANGED
|
@@ -99,9 +99,14 @@ var NUTRIENT_EXAMPLES = {
|
|
|
99
99
|
mg: 'milligrams, e.g. 300, "300 mg", or "0.3 g"'
|
|
100
100
|
};
|
|
101
101
|
function parseRecipe(input, options) {
|
|
102
|
+
return parseRecipeWith(
|
|
103
|
+
input,
|
|
104
|
+
readLineOption("parseRecipe", options) ?? "pass parseIngredientLine: parseRecipe(input, { parseIngredientLine })"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
function parseRecipeWith(input, lines) {
|
|
102
108
|
const issues = [];
|
|
103
|
-
const
|
|
104
|
-
const recipe = readRecipe(issues, input, readLine);
|
|
109
|
+
const recipe = readRecipe(issues, input, lines);
|
|
105
110
|
const label = recipe && quote(recipe.name);
|
|
106
111
|
return toResult("invalid_recipe", recipe, issues, label);
|
|
107
112
|
}
|
|
@@ -115,7 +120,7 @@ function readLineOption(fn, options) {
|
|
|
115
120
|
}
|
|
116
121
|
return reader;
|
|
117
122
|
}
|
|
118
|
-
function readRecipe(issues, value,
|
|
123
|
+
function readRecipe(issues, value, lines) {
|
|
119
124
|
const input = asRecord(value);
|
|
120
125
|
if (!input) {
|
|
121
126
|
return error(
|
|
@@ -145,7 +150,7 @@ function readRecipe(issues, value, readLine) {
|
|
|
145
150
|
input.ingredients,
|
|
146
151
|
["ingredients"],
|
|
147
152
|
"ingredients",
|
|
148
|
-
(issues2, value2, path) => readIngredient(issues2, value2, path,
|
|
153
|
+
(issues2, value2, path) => readIngredient(issues2, value2, path, lines)
|
|
149
154
|
);
|
|
150
155
|
const steps = readList(
|
|
151
156
|
issues,
|
|
@@ -195,17 +200,13 @@ function readRecipe(issues, value, readLine) {
|
|
|
195
200
|
});
|
|
196
201
|
}
|
|
197
202
|
function readList(issues, pending, value, path, noun, readItem) {
|
|
198
|
-
const entries = value === void 0 ? [] : readArray(issues, value, path, noun);
|
|
199
|
-
if (!entries) {
|
|
200
|
-
return [];
|
|
201
|
-
}
|
|
202
203
|
const sections = [];
|
|
203
204
|
let loose;
|
|
204
205
|
let count = 0;
|
|
205
|
-
const read = (
|
|
206
|
-
|
|
206
|
+
const read = (entry2, entryPath) => readItem(issues, entry2, entryPath, ++count);
|
|
207
|
+
const visit = (entry2, i) => {
|
|
207
208
|
const entryPath = [...path, i];
|
|
208
|
-
const section = asSection(
|
|
209
|
+
const section = asSection(entry2);
|
|
209
210
|
if (section) {
|
|
210
211
|
loose = void 0;
|
|
211
212
|
sections.push(readSection(issues, section, entryPath, noun, read));
|
|
@@ -222,20 +223,28 @@ function readList(issues, pending, value, path, noun, readItem) {
|
|
|
222
223
|
};
|
|
223
224
|
sections.push(loose);
|
|
224
225
|
}
|
|
225
|
-
const item = read(
|
|
226
|
+
const item = read(entry2, entryPath);
|
|
226
227
|
if (item) {
|
|
227
228
|
loose.items.push(item);
|
|
228
229
|
}
|
|
229
|
-
}
|
|
230
|
+
};
|
|
231
|
+
if (value !== void 0 && !readArray(issues, value, path, noun, visit)) {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
230
234
|
if (sections.length === 0) {
|
|
231
235
|
warning(issues, "empty_list", path, `Recipe has no ${noun}.`);
|
|
232
236
|
return [];
|
|
233
237
|
}
|
|
234
|
-
const
|
|
238
|
+
const renamed = [];
|
|
239
|
+
const sectionIds = assignIds(sections, renamed);
|
|
235
240
|
const itemIds = assignIds(
|
|
236
241
|
sections.flatMap((section) => section.items),
|
|
237
|
-
|
|
242
|
+
renamed
|
|
238
243
|
);
|
|
244
|
+
const entry = ({ issue }) => issue.path[path.length];
|
|
245
|
+
for (const issue of renamed.sort((a, b) => entry(a) - entry(b))) {
|
|
246
|
+
pending.push(issue);
|
|
247
|
+
}
|
|
239
248
|
let next = 0;
|
|
240
249
|
return sections.map(
|
|
241
250
|
(section, i) => defined({
|
|
@@ -255,10 +264,13 @@ function readSection(issues, record, path, noun, read) {
|
|
|
255
264
|
const title = readText(issues, record.title, [...path, "title"]);
|
|
256
265
|
const items = [];
|
|
257
266
|
const itemsPath = [...path, "items"];
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
267
|
+
let visited = 0;
|
|
268
|
+
const entries = readArray(
|
|
269
|
+
issues,
|
|
270
|
+
record.items,
|
|
271
|
+
itemsPath,
|
|
272
|
+
noun,
|
|
273
|
+
(entry, j) => {
|
|
262
274
|
visited++;
|
|
263
275
|
const itemPath = [...itemsPath, j];
|
|
264
276
|
if (asSection(entry)) {
|
|
@@ -274,10 +286,10 @@ function readSection(issues, record, path, noun, read) {
|
|
|
274
286
|
if (item) {
|
|
275
287
|
items.push(item);
|
|
276
288
|
}
|
|
277
|
-
});
|
|
278
|
-
if (visited === 0) {
|
|
279
|
-
warning(issues, "empty_list", itemsPath, `Section has no ${noun}.`);
|
|
280
289
|
}
|
|
290
|
+
);
|
|
291
|
+
if (entries && visited === 0) {
|
|
292
|
+
warning(issues, "empty_list", itemsPath, `Section has no ${noun}.`);
|
|
281
293
|
}
|
|
282
294
|
checkFields(issues, record, SECTION_FIELDS, path);
|
|
283
295
|
return {
|
|
@@ -290,29 +302,44 @@ function readSection(issues, record, path, noun, read) {
|
|
|
290
302
|
};
|
|
291
303
|
}
|
|
292
304
|
function readItems(issues, pending, value, path, noun, readItem) {
|
|
293
|
-
|
|
294
|
-
|
|
305
|
+
let n = 0;
|
|
306
|
+
const drafts = value === void 0 ? [] : readArray(
|
|
307
|
+
issues,
|
|
308
|
+
value,
|
|
309
|
+
path,
|
|
310
|
+
noun,
|
|
311
|
+
(entry, i) => readItem(issues, entry, [...path, i], ++n)
|
|
312
|
+
);
|
|
313
|
+
if (!drafts) {
|
|
295
314
|
return [];
|
|
296
315
|
}
|
|
297
|
-
let n = 0;
|
|
298
|
-
const drafts = entries.flatMap((entry, i) => {
|
|
299
|
-
const draft = readItem(issues, entry, [...path, i], ++n);
|
|
300
|
-
return draft ? [draft] : [];
|
|
301
|
-
});
|
|
302
316
|
const ids = assignIds(drafts, pending);
|
|
303
317
|
return drafts.map((draft, i) => draft.build(ids[i]));
|
|
304
318
|
}
|
|
305
|
-
function readArray(issues, value, path, noun) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
319
|
+
function readArray(issues, value, path, noun, read) {
|
|
320
|
+
if (!Array.isArray(value)) {
|
|
321
|
+
return error(
|
|
322
|
+
issues,
|
|
323
|
+
"invalid_type",
|
|
324
|
+
path,
|
|
325
|
+
`Expected a list of ${noun}, received ${describeValue(value)}.`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const kept = [];
|
|
329
|
+
for (let i = 0, n = value.length; i < n; i++) {
|
|
330
|
+
if (i in value) {
|
|
331
|
+
const entry = value[i];
|
|
332
|
+
const result = hasOwn(value, i) && read(entry, i);
|
|
333
|
+
if (result) {
|
|
334
|
+
kept.push(result);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return kept;
|
|
312
339
|
}
|
|
313
|
-
function readIngredient(issues, value, path,
|
|
314
|
-
if (typeof value === "string" &&
|
|
315
|
-
const result =
|
|
340
|
+
function readIngredient(issues, value, path, lines) {
|
|
341
|
+
if (typeof value === "string" && typeof lines === "function") {
|
|
342
|
+
const result = lines(value);
|
|
316
343
|
for (const issue of result.success ? result.warnings : result.error.issues) {
|
|
317
344
|
issues.push({ ...issue, path: [...path, ...issue.path] });
|
|
318
345
|
}
|
|
@@ -324,7 +351,7 @@ function readIngredient(issues, value, path, readLine) {
|
|
|
324
351
|
issues,
|
|
325
352
|
"invalid_type",
|
|
326
353
|
path,
|
|
327
|
-
`Expected an ingredient such as ${
|
|
354
|
+
`Expected an ingredient such as ${typeof lines === "function" ? '"2 cups flour" or ' : ""}{ name: "flour", quantity: "2 cups" }, received ${describeValue(value)}.${typeof value === "string" && typeof lines === "string" ? ` To read ingredient lines, ${lines}.` : ""}`
|
|
328
355
|
);
|
|
329
356
|
}
|
|
330
357
|
const position = issues.length;
|
|
@@ -365,22 +392,13 @@ function readIngredient(issues, value, path, readLine) {
|
|
|
365
392
|
};
|
|
366
393
|
}
|
|
367
394
|
function readEquivalents(issues, value, path) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
path,
|
|
376
|
-
`Expected a list of quantities, e.g. ["190 g"], received ${describeValue(value)}.`
|
|
377
|
-
);
|
|
378
|
-
return [];
|
|
379
|
-
}
|
|
380
|
-
return value.flatMap((entry, i) => {
|
|
381
|
-
const quantity = readQuantity(issues, entry, [...path, i]);
|
|
382
|
-
return quantity === void 0 ? [] : [quantity];
|
|
383
|
-
});
|
|
395
|
+
return value !== void 0 && readArray(
|
|
396
|
+
issues,
|
|
397
|
+
value,
|
|
398
|
+
path,
|
|
399
|
+
'quantities, e.g. ["190 g"]',
|
|
400
|
+
(entry, i) => readQuantity(issues, entry, [...path, i])
|
|
401
|
+
) || [];
|
|
384
402
|
}
|
|
385
403
|
function readStep(issues, value, path, n) {
|
|
386
404
|
const isText = typeof value === "string";
|
|
@@ -615,10 +633,11 @@ function readRangeText(issues, text, path, expected) {
|
|
|
615
633
|
return error(issues, match.code, path, match.message);
|
|
616
634
|
}
|
|
617
635
|
const { amount, rest } = match;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
636
|
+
const range = typeof amount === "number" ? { amount } : { amount: amount.min, max: amount.max };
|
|
637
|
+
if (readPositive(issues, range.amount, path) === void 0 || range.max !== void 0 && readPositive(issues, range.max, path) === void 0) {
|
|
638
|
+
return void 0;
|
|
639
|
+
}
|
|
640
|
+
return { range, rest };
|
|
622
641
|
}
|
|
623
642
|
function readRange(issues, record, path, missingAmount) {
|
|
624
643
|
const value = record.amount;
|
|
@@ -834,6 +853,7 @@ function readBoolean(issues, value, path) {
|
|
|
834
853
|
}
|
|
835
854
|
export {
|
|
836
855
|
parseRecipe,
|
|
856
|
+
parseRecipeWith,
|
|
837
857
|
readLineOption
|
|
838
858
|
};
|
|
839
859
|
//# sourceMappingURL=parse.js.map
|