@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-VWVIBCTG.cjs → chunk-ISRDJR2O.cjs} +227 -179
  4. package/dist/chunk-ISRDJR2O.cjs.map +1 -0
  5. package/dist/errors.js +29 -1
  6. package/dist/errors.js.map +1 -1
  7. package/dist/format/duration.js.map +1 -1
  8. package/dist/format/ingredient.js.map +1 -1
  9. package/dist/format/number.js +1 -1
  10. package/dist/format/number.js.map +1 -1
  11. package/dist/format/options.js.map +1 -1
  12. package/dist/format/quantity.js.map +1 -1
  13. package/dist/format/servings.js.map +1 -1
  14. package/dist/format/temperature.js.map +1 -1
  15. package/dist/index.cjs +87 -74
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +71 -28
  18. package/dist/index.d.ts +71 -28
  19. package/dist/index.js +6 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/ingredient/parse.js +27 -22
  22. package/dist/ingredient/parse.js.map +1 -1
  23. package/dist/issues.js.map +1 -1
  24. package/dist/quantity/amount.js +11 -0
  25. package/dist/quantity/amount.js.map +1 -1
  26. package/dist/quantity/parse.js +58 -81
  27. package/dist/quantity/parse.js.map +1 -1
  28. package/dist/quantity/scale.js +5 -2
  29. package/dist/quantity/scale.js.map +1 -1
  30. package/dist/recipe/ids.js.map +1 -1
  31. package/dist/recipe/parse.js +80 -60
  32. package/dist/recipe/parse.js.map +1 -1
  33. package/dist/recipe/schema.js +8 -6
  34. package/dist/recipe/schema.js.map +1 -1
  35. package/dist/resolve.js +6 -1
  36. package/dist/resolve.js.map +1 -1
  37. package/dist/schema-org/find.js +46 -13
  38. package/dist/schema-org/find.js.map +1 -1
  39. package/dist/schema-org/from-json-ld.js +134 -71
  40. package/dist/schema-org/from-json-ld.js.map +1 -1
  41. package/dist/schema-org/text.js.map +1 -1
  42. package/dist/schema-org/to-json-ld.js +12 -15
  43. package/dist/schema-org/to-json-ld.js.map +1 -1
  44. package/dist/schema-org.cjs +239 -147
  45. package/dist/schema-org.cjs.map +1 -1
  46. package/dist/schema-org.d.cts +35 -21
  47. package/dist/schema-org.d.ts +35 -21
  48. package/dist/steps/segment.js.map +1 -1
  49. package/dist/steps/temperature.js +13 -13
  50. package/dist/steps/temperature.js.map +1 -1
  51. package/dist/{types-DSMf45H5.d.cts → types-CKIoWsz8.d.cts} +25 -7
  52. package/dist/{types-DSMf45H5.d.ts → types-CKIoWsz8.d.ts} +25 -7
  53. package/dist/units/convert.js +16 -8
  54. package/dist/units/convert.js.map +1 -1
  55. package/dist/units/temperature.js +7 -1
  56. package/dist/units/temperature.js.map +1 -1
  57. package/dist/validation.js +1 -1
  58. package/dist/validation.js.map +1 -1
  59. package/package.json +1 -1
  60. package/dist/chunk-VWVIBCTG.cjs.map +0 -1
@@ -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 VULGAR_FRACTIONS = {
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 patterns;
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
- return readQuantityText(issues, value, path);
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
- return readQuantityObject(issues, record, path);
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
- const match = getPatterns().amount.exec(text);
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 = "", highTextDash, highTextTo] = match;
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 = getPatterns().asciiFraction.exec(text);
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 vulgar = VULGAR_FRACTIONS[text.slice(-1)];
187
- if (vulgar) {
188
- const [numerator, denominator] = vulgar;
189
- return Number(text.slice(0, -1)) + numerator / denominator;
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"]}
@@ -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
- return multiply(quantity, factor);
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 return multiply(quantity, factor)\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,OAEK;AAiBA,SAAS,cACd,UACA,QACsD;AACtD,gBAAc,iBAAiB,QAAQ;AACvC,cAAY,iBAAiB,UAAU,MAAM;AAE7C,SAAO,SAAS,UAAU,MAAM;AAClC;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":[]}
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":[]}
@@ -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, without accents on 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;AAmB1B,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"]}
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"]}
@@ -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 readLine = readLineOption("parseRecipe", options);
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, readLine) {
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, readLine)
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 = (entry, entryPath) => readItem(issues, entry, entryPath, ++count);
206
- entries.forEach((entry, i) => {
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(entry);
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(entry, entryPath);
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 sectionIds = assignIds(sections, pending);
238
+ const renamed = [];
239
+ const sectionIds = assignIds(sections, renamed);
235
240
  const itemIds = assignIds(
236
241
  sections.flatMap((section) => section.items),
237
- pending
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
- const entries = readArray(issues, record.items, itemsPath, noun);
259
- if (entries) {
260
- let visited = 0;
261
- entries.forEach((entry, j) => {
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
- const entries = value === void 0 ? [] : readArray(issues, value, path, noun);
294
- if (!entries) {
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
- return Array.isArray(value) ? value : error(
307
- issues,
308
- "invalid_type",
309
- path,
310
- `Expected a list of ${noun}, received ${describeValue(value)}.`
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, readLine) {
314
- if (typeof value === "string" && readLine) {
315
- const result = readLine(value);
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 ${readLine ? '"2 cups flour" or ' : ""}{ name: "flour", quantity: "2 cups" }, received ${describeValue(value)}.${typeof value === "string" ? " To read ingredient lines, pass parseIngredientLine: parseRecipe(input, { parseIngredientLine })." : ""}`
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
- if (value === void 0) {
369
- return [];
370
- }
371
- if (!Array.isArray(value)) {
372
- error(
373
- issues,
374
- "invalid_type",
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
- return {
619
- range: typeof amount === "number" ? { amount } : { amount: amount.min, max: amount.max },
620
- rest
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