@recipe-book/core 0.1.0 → 0.1.1

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 (50) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-VWVIBCTG.cjs → chunk-D5CV3UF7.cjs} +188 -173
  4. package/dist/chunk-D5CV3UF7.cjs.map +1 -0
  5. package/dist/errors.js.map +1 -1
  6. package/dist/format/number.js +1 -1
  7. package/dist/format/number.js.map +1 -1
  8. package/dist/format/options.js.map +1 -1
  9. package/dist/index.cjs +83 -74
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +35 -19
  12. package/dist/index.d.ts +35 -19
  13. package/dist/ingredient/parse.js +27 -22
  14. package/dist/ingredient/parse.js.map +1 -1
  15. package/dist/issues.js.map +1 -1
  16. package/dist/quantity/amount.js +11 -0
  17. package/dist/quantity/amount.js.map +1 -1
  18. package/dist/quantity/parse.js +58 -81
  19. package/dist/quantity/parse.js.map +1 -1
  20. package/dist/quantity/scale.js +5 -2
  21. package/dist/quantity/scale.js.map +1 -1
  22. package/dist/recipe/parse.js +70 -55
  23. package/dist/recipe/parse.js.map +1 -1
  24. package/dist/recipe/schema.js +8 -6
  25. package/dist/recipe/schema.js.map +1 -1
  26. package/dist/resolve.js +6 -1
  27. package/dist/resolve.js.map +1 -1
  28. package/dist/schema-org/find.js +46 -13
  29. package/dist/schema-org/find.js.map +1 -1
  30. package/dist/schema-org/from-json-ld.js +127 -57
  31. package/dist/schema-org/from-json-ld.js.map +1 -1
  32. package/dist/schema-org/text.js.map +1 -1
  33. package/dist/schema-org/to-json-ld.js +12 -15
  34. package/dist/schema-org/to-json-ld.js.map +1 -1
  35. package/dist/schema-org.cjs +231 -132
  36. package/dist/schema-org.cjs.map +1 -1
  37. package/dist/schema-org.d.cts +32 -19
  38. package/dist/schema-org.d.ts +32 -19
  39. package/dist/steps/temperature.js +13 -13
  40. package/dist/steps/temperature.js.map +1 -1
  41. package/dist/{types-DSMf45H5.d.cts → types-BknM8KOd.d.cts} +4 -3
  42. package/dist/{types-DSMf45H5.d.ts → types-BknM8KOd.d.ts} +4 -3
  43. package/dist/units/convert.js +16 -8
  44. package/dist/units/convert.js.map +1 -1
  45. package/dist/units/temperature.js +7 -1
  46. package/dist/units/temperature.js.map +1 -1
  47. package/dist/validation.js +1 -1
  48. package/dist/validation.js.map +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-VWVIBCTG.cjs.map +0 -1
@@ -5,18 +5,27 @@ import { ParseResult, Recipe, System, RecipeIssue } from './index.js';
5
5
  * `<script type="application/ld+json">` tags, without a DOM. Pass one to
6
6
  * `fromJsonLd`.
7
7
  *
8
- * Reads arrays and `@graph`, as Yoast and WP Recipe Maker write them, and a
9
- * Recipe in a node's `mainEntity`, and matches a node whose `@type` is or
10
- * includes `"Recipe"`. A tag whose JSON doesn't parse is retried with the raw
11
- * line breaks and tabs inside its strings escaped, as real pages have them,
12
- * and skipped if it still doesn't. A tag inside an HTML comment is skipped,
13
- * and a `<!--` in an attribute value or in a `style` or `textarea` element is
14
- * text, as in a browser. A quote starts an attribute value only right after
15
- * `=`, so a stray one, as in `content="a 9" pan"`, doesn't hide later tags. A
16
- * Recipe node with the `@id` of an earlier one is returned once, where the
17
- * first appears. When the first holds only keywords, such as a `mainEntity`
18
- * of `{ "@type": "Recipe", "@id": "#recipe" }`, the later node is returned in
19
- * its place. An empty `@id` is no `@id`, so it merges nothing.
8
+ * Reads arrays and `@graph`, as Yoast and WP Recipe Maker write them, and the
9
+ * `mainEntity` of a node there, up to 32 levels deep, and matches a node whose
10
+ * `@type` is or includes `"Recipe"`. When that finds none, it also reads each
11
+ * `ItemList` in those places, as a roundup page lists its recipes, taking each
12
+ * Recipe in its `itemListElement`, or in a `ListItem`'s `item` there. A
13
+ * `ListItem` with only a `url` is skipped: fetch that page and call
14
+ * `findRecipeJsonLd` on it. A `mainEntity`, or an entry of a `mainEntity`
15
+ * array, that is neither a Recipe nor an `ItemList` is skipped with everything
16
+ * in it, and a Recipe anywhere else, such as inside another Recipe or in a list
17
+ * inside a `ListItem` or another list, isn't found.
18
+ *
19
+ * A tag whose JSON doesn't parse is retried with the raw line breaks and tabs
20
+ * inside its strings escaped, as real pages have them, and skipped if it still
21
+ * doesn't. A tag inside an HTML comment is skipped, and a `<!--` in an
22
+ * attribute value or in a `style` or `textarea` element is text, as in a
23
+ * browser. A quote starts an attribute value only right after `=`, so a stray
24
+ * one, as in `content="a 9" pan"`, doesn't hide later tags. A Recipe node with
25
+ * the `@id` of an earlier one is returned once, where the first appears. When
26
+ * the first holds only keywords, such as a `mainEntity` of
27
+ * `{ "@type": "Recipe", "@id": "#recipe" }`, the later node is returned in its
28
+ * place. An empty `@id` is no `@id`, so it merges nothing.
20
29
  *
21
30
  * In each Recipe node returned, an image that is an `@id` reference to a node
22
31
  * at the top level of the JSON-LD, or reached from there through arrays and
@@ -29,7 +38,8 @@ import { ParseResult, Recipe, System, RecipeIssue } from './index.js';
29
38
  * holds, stay as the page wrote them.
30
39
  *
31
40
  * @param html - The page's HTML, such as `await response.text()`.
32
- * @returns Every Recipe node, in document order, or `[]` when there are none.
41
+ * @returns The Recipe nodes it finds, in document order, or `[]` when there
42
+ * are none.
33
43
  * @throws {RecipeBookError} `invalid_argument` when `html` isn't a string.
34
44
  *
35
45
  * @example
@@ -136,10 +146,11 @@ type FromJsonLdResult = ParseResult<Recipe> & {
136
146
 
137
147
  /**
138
148
  * Reads a schema.org Recipe from JSON-LD, as recipe sites publish it for
139
- * search engines: a Recipe node, or an array, `@graph`, or `mainEntity` that
140
- * holds one, such as a node from `findRecipeJsonLd` or `JSON.parse` of a
141
- * script tag. Never throws for bad data; an error thrown by a getter or proxy
142
- * in the input passes through.
149
+ * search engines: a Recipe node, or an array, `@graph`, `mainEntity`, or
150
+ * `ItemList` that holds one, such as a node from `findRecipeJsonLd` or
151
+ * `JSON.parse` of a script tag. An `ItemList` is read only when no other
152
+ * Recipe is found, as in `findRecipeJsonLd`. Never throws for bad data; an
153
+ * error thrown by a getter or proxy in the input passes through.
143
154
  *
144
155
  * Every string has its tags removed, then its HTML entities decoded, so an
145
156
  * encoded tag such as `&lt;b&gt;` stays as text. Ingredient lines are read
@@ -154,8 +165,10 @@ type FromJsonLdResult = ParseResult<Recipe> & {
154
165
  * text, or a problem remains that leaving a value out can't fix.
155
166
  *
156
167
  * @returns The recipe or the error, and `extra`: the node's fields the recipe
157
- * doesn't model, such as `author` and `keywords`, as they were. Pass `extra`
158
- * to `toJsonLd` to write them back. It is `{}` when no Recipe node is found.
168
+ * doesn't model, such as `author` and `keywords`, as they were. A field that
169
+ * nests more than 32 levels deep, counted from the top of `input`, is left
170
+ * out, with a warning. Pass `extra` to `toJsonLd` to write them back. It is
171
+ * `{}` when no Recipe node is found.
159
172
  *
160
173
  * @example
161
174
  * const result = fromJsonLd(node)
@@ -2,13 +2,13 @@
2
2
  var LOOKBACK = 24;
3
3
  var MAX_ITEMS = 6;
4
4
  var NUMBER = /(\d{1,4})(\.\d{1,2})?(?![.,]?\d)/y;
5
- var DEGREES = /(?:-|\s{0,3})(?:([℃℉])|(?:[°º˚]|degrees?\b)(?:\s{0,3}(?:(cel[cs]ius|centigrade|fahrenheit|[cfс])(?!\w)|\(([cf])\)))?)/iy;
6
- var SCALE = /(\s{0,3})(cel[cs]ius|centigrade|fahrenheit|[cf])(?!\w)(?:°|\s{1,3}degrees?\b)?/iy;
7
- var FAN_AFTER = /(?:\s{1,3}|-)(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\b/iy;
5
+ var DEGREES = /(?:-|\s{0,3})(?:([℃℉])|(?:[°º˚]|degrees?(?!(?!ι)[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_]))(?:\s{0,3}(?:(cel[cs]ius|centigrade|fahrenheit|[cfс])(?!(?![ºªι])[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])|\(([cf])\)))?)/iuy;
6
+ var SCALE = /(\s{0,3})(cel[cs]ius|centigrade|fahrenheit|[cf])(?!(?![ºªι])[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])(?:°|\s{1,3}degrees?(?!(?!ι)[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_]))?/iuy;
7
+ var FAN_AFTER = /(?:\s{1,3}|-)(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)(?!(?!ι)[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])/iuy;
8
8
  var FAN_GROUP = /\s{0,3}\(\s{0,2}(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\s{0,2}\)/iy;
9
9
  var FAN_BEFORE = /(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\s{1,3}/iy;
10
- var FAN_PREFIX = /\b(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\s{1,3}$/i;
11
- var GAS = /(?:gas(?:\s{1,3}mark)?|mark)\s{0,3}(?:\d{1,2}(?:[-/\u2013]\d{1,2})?|[¼½¾])(?!\w)/iy;
10
+ var FAN_PREFIX = /(?:^|ι|[^\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])((?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\s{1,3})$/iu;
11
+ var GAS = /(?:gas(?:\s{1,3}mark)?|mark)\s{0,3}(?:\d{1,2}(?:[-/\u2013]\d{1,2})?|[¼½¾])(?!(?!ι)[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])/iuy;
12
12
  var SEPARATOR = /\s{0,3}(?:(?:,?\s{0,3}or(?=\s)|[-/,\u2013\u2014])\s{0,3})?/iy;
13
13
  var OPEN = /\s{0,3}\(\s{0,2}(?:(?:about|around|roughly|approx(?:imately|\.)?|ca\.|i\.e\.,?|or)\s{1,3}|[~≈]\s{0,2})?/iy;
14
14
  var CLOSE = /\s{0,2}\)/y;
@@ -16,24 +16,24 @@ var OVEN_STEP = 10;
16
16
  var SHARED = /\d\s{0,3}(?:(?:[°º˚]|degrees?)\s{0,3}\(|(?:[°º˚]|degrees?)?\s{0,3}(?:[-\u2010-\u2015~/&]|to|and|or))\s{0,3}$/i;
17
17
  var GAS_ALTERNATIVE = /(?:gas|mark)\s{0,3}\d{1,2}\s{0,3}(?:\/|or)\s{0,3}$/i;
18
18
  var RANGE_AFTER = /\s{0,3}(?:[-\u2010-\u2015~/&]|to|and|or)\s{0,3}-?\d/iy;
19
- var NOT_A_READING = /\d[.,:]$|\bby\s{1,3}(?:(?:about|around|roughly|approximately|another|an?|further|additional|extra)\s{1,3}){0,3}$/i;
20
- var SETTING = /\b(?:at|to|of|be|is|until|oven|bake|roast|reads?|reach(?:es)?|registers?|hits?)\s{1,3}(?:(?:about|around|roughly|approximately)\s{1,3})?$/i;
21
- var OVEN_AFTER = /\s{1,3}oven\b/iy;
19
+ var NOT_A_READING = /\d[.,:]$|(?:^|ι|[^\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])by\s{1,3}(?:(?:about|around|roughly|approximately|another|an?|further|additional|extra)\s{1,3}){0,3}$/iu;
20
+ var SETTING = /(?:^|ι|[^\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])(?:at|to|of|be|is|until|oven|bake|roast|reads?|reach(?:es)?|registers?|hits?)\s{1,3}(?:(?:about|around|roughly|approximately)\s{1,3})?$/iu;
21
+ var OVEN_AFTER = /\s{1,3}oven(?!(?!ι)[\p{sc=Latin}\p{sc=Cyrillic}\p{M}\d_])/iuy;
22
22
  var SIGN = /[-\u2212]/;
23
23
  var SIGN_BEFORE = /[\s(]/;
24
24
  var LETTER = /[a-z]/i;
25
25
  function matchTemperature(text, digit, floor, assumed) {
26
- const negative = digit > floor && SIGN.test(text[digit - 1] ?? "") && SIGN_BEFORE.test(text[digit - 2] ?? " ");
26
+ const negative = digit > floor && SIGN.test(text[digit - 1] ?? "") && SIGN_BEFORE.test(text.charAt(digit - 2) || " ");
27
27
  const start = negative ? digit - 1 : digit;
28
28
  const before = text.slice(Math.max(floor, start - LOOKBACK), start);
29
- if (LETTER.test(text[start - 1] ?? "") || NOT_A_READING.test(before) || SHARED.test(before) && !GAS_ALTERNATIVE.test(before)) {
29
+ if (LETTER.test(text.charAt(start - 1)) || NOT_A_READING.test(before) || SHARED.test(before) && !GAS_ALTERNATIVE.test(before)) {
30
30
  return void 0;
31
31
  }
32
32
  const first = readingAt(text, digit);
33
33
  if (!first) {
34
34
  return void 0;
35
35
  }
36
- const prefix = FAN_PREFIX.exec(before)?.[0];
36
+ const prefix = FAN_PREFIX.exec(before)?.[1];
37
37
  const unit = first.unit ?? assumed;
38
38
  if (!unit || !first.unit && exec(RANGE_AFTER, text, first.end)) {
39
39
  return void 0;
@@ -47,7 +47,7 @@ function matchTemperature(text, digit, floor, assumed) {
47
47
  };
48
48
  const run = { readings: [reading], gas: false };
49
49
  let end = extend(text, first.end, run);
50
- if (first.cued && !run.readings[1] && !(prefix || SETTING.test(before) || exec(OVEN_AFTER, text, first.end))) {
50
+ if (first.cued && run.readings.length < 2 && !(prefix || SETTING.test(before) || exec(OVEN_AFTER, text, first.end))) {
51
51
  return void 0;
52
52
  }
53
53
  const fanEnd = text[end - 1] === ")" && !run.readings.some(({ fan }) => fan) ? at(FAN_AFTER, text, end) : void 0;
@@ -98,7 +98,7 @@ function extend(text, end, run, grouped) {
98
98
  if (next === void 0) {
99
99
  break;
100
100
  }
101
- owner = run.readings[size] ? [end, size] : void 0;
101
+ owner = run.readings.length > size ? [end, size] : void 0;
102
102
  end = next;
103
103
  }
104
104
  return end;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/steps/temperature.ts"],"sourcesContent":["import type { Temperature, TemperatureUnit } from '../units/temperature'\nimport type { TemperatureReading } from './segment'\n\ntype TemperatureMatch = {\n start: number\n end: number\n reading: TemperatureReading\n alternatives: TemperatureReading[]\n}\n\ntype Reading = {\n value: number\n unit: TemperatureUnit | undefined\n fan: boolean\n /** True when it counts only after a word such as \"at\", as \"350°\" and \"180 C\" do. */\n cued: boolean\n end: number\n}\n\n/**\n * The readings in a run, first included, and whether a gas mark was among\n * them. Readings for the same kind of oven are one setting written in both\n * units, so \"350°F or 375°F\" and \"350°F or 200°C\" are separate settings.\n * `clash` is set when a reading was turned away for that reason.\n */\ntype Run = { readings: TemperatureReading[]; gas: boolean; clash?: boolean }\n\n// Every quantifier is bounded, lookbehind reads at most LOOKBACK characters,\n// and a run holds at most MAX_ITEMS items, so each call does constant work and\n// a scan stays linear in the text length.\nconst LOOKBACK = 24\nconst MAX_ITEMS = 6\n\n// The lookahead never changes a reading, since no scale starts with a digit,\n// \".\" or \",\". It's there for speed: it turns a longer number away before\n// DEGREES and SCALE are tried.\nconst NUMBER = /(\\d{1,4})(\\.\\d{1,2})?(?![.,]?\\d)/y\n// Word boundaries are ASCII, so a scale can sit right before Chinese text, as\n// in \"45°C/113°F的温水\". The scale may be a Cyrillic \"С\", as in \"180 °С\".\nconst DEGREES =\n /(?:-|\\s{0,3})(?:([℃℉])|(?:[°º˚]|degrees?\\b)(?:\\s{0,3}(?:(cel[cs]ius|centigrade|fahrenheit|[cfс])(?!\\w)|\\(([cf])\\)))?)/iy\nconst SCALE =\n /(\\s{0,3})(cel[cs]ius|centigrade|fahrenheit|[cf])(?!\\w)(?:°|\\s{1,3}degrees?\\b)?/iy\nconst FAN_AFTER =\n /(?:\\s{1,3}|-)(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\b/iy\nconst FAN_GROUP =\n /\\s{0,3}\\(\\s{0,2}(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\s{0,2}\\)/iy\nconst FAN_BEFORE =\n /(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\s{1,3}/iy\nconst FAN_PREFIX =\n /\\b(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\s{1,3}$/i\nconst GAS =\n /(?:gas(?:\\s{1,3}mark)?|mark)\\s{0,3}(?:\\d{1,2}(?:[-/\\u2013]\\d{1,2})?|[¼½¾])(?!\\w)/iy\nconst SEPARATOR = /\\s{0,3}(?:(?:,?\\s{0,3}or(?=\\s)|[-/,\\u2013\\u2014])\\s{0,3})?/iy\n// A parenthesis may hedge what it restates, as in \"350°F (about 180°C)\".\nconst OPEN =\n /\\s{0,3}\\(\\s{0,2}(?:(?:about|around|roughly|approx(?:imately|\\.)?|ca\\.|i\\.e\\.,?|or)\\s{1,3}|[~≈]\\s{0,2})?/iy\nconst CLOSE = /\\s{0,2}\\)/y\n// Readings this close are one setting written in both units: 170°C is 338°F,\n// and recipes round it to 325°F or 350°F.\nconst OVEN_STEP = 10\n\n// A number that takes its scale from a later one, as 350 does in \"350–375°F\",\n// \"350 to 375 degrees F\", and \"350° (175°C)\". The later one stays text too,\n// so a converter never rewrites one end of a range.\nconst SHARED =\n /\\d\\s{0,3}(?:(?:[°º˚]|degrees?)\\s{0,3}\\(|(?:[°º˚]|degrees?)?\\s{0,3}(?:[-\\u2010-\\u2015~/&]|to|and|or))\\s{0,3}$/i\n// A gas mark before \"/\" or \"or\" has its own scale, so the 200C in\n// \"gas 6/200C\" and \"gas mark 6 or 200C\" is read. Other joiners can start a\n// range, as in \"gas 4–5\" and \"gas mark 4 to 5\", so SHARED still applies.\nconst GAS_ALTERNATIVE = /(?:gas|mark)\\s{0,3}\\d{1,2}\\s{0,3}(?:\\/|or)\\s{0,3}$/i\nconst RANGE_AFTER = /\\s{0,3}(?:[-\\u2010-\\u2015~/&]|to|and|or)\\s{0,3}-?\\d/iy\n// A number that continues another, as in \"1.5\" and \"10:30\", isn't a reading,\n// and \"Reduce the heat by 20°F\" is a difference; converting it is wrong.\nconst NOT_A_READING =\n /\\d[.,:]$|\\bby\\s{1,3}(?:(?:about|around|roughly|approximately|another|an?|further|additional|extra)\\s{1,3}){0,3}$/i\n// Words that make a bare \"350°\" or \"350 degrees\" an oven setting rather than\n// an angle, as in \"rotate the pan 180 degrees\", and \"180 C\" a temperature\n// rather than cups, as in \"add 100 c sugar\".\nconst SETTING =\n /\\b(?:at|to|of|be|is|until|oven|bake|roast|reads?|reach(?:es)?|registers?|hits?)\\s{1,3}(?:(?:about|around|roughly|approximately)\\s{1,3})?$/i\nconst OVEN_AFTER = /\\s{1,3}oven\\b/iy\n\nconst SIGN = /[-\\u2212]/\nconst SIGN_BEFORE = /[\\s(]/\n// ASCII only: Chinese puts numbers right after words, as in \"预热至200°C\".\nconst LETTER = /[a-z]/i\n\n/**\n * Matches a temperature, or a run of readings that state one setting several\n * ways, whose first number starts at `digit`. Text before `floor` belongs to\n * an earlier segment and is never included.\n */\nexport function matchTemperature(\n text: string,\n digit: number,\n floor: number,\n assumed: TemperatureUnit | undefined,\n): TemperatureMatch | undefined {\n const negative =\n digit > floor &&\n SIGN.test(text[digit - 1] ?? '') &&\n SIGN_BEFORE.test(text[digit - 2] ?? ' ')\n const start = negative ? digit - 1 : digit\n const before = text.slice(Math.max(floor, start - LOOKBACK), start)\n if (\n LETTER.test(text[start - 1] ?? '') ||\n NOT_A_READING.test(before) ||\n (SHARED.test(before) && !GAS_ALTERNATIVE.test(before))\n ) {\n return undefined\n }\n\n const first = readingAt(text, digit)\n if (!first) {\n return undefined\n }\n\n const prefix = FAN_PREFIX.exec(before)?.[0]\n const unit = first.unit ?? assumed\n if (!unit || (!first.unit && exec(RANGE_AFTER, text, first.end))) {\n return undefined\n }\n\n const reading: TemperatureReading = {\n temperature: {\n value: negative && first.value ? -first.value : first.value,\n unit,\n },\n fan: Boolean(prefix) || first.fan,\n }\n const run: Run = { readings: [reading], gas: false }\n let end = extend(text, first.end, run)\n // A reading that needs a cue word is a temperature without one when the\n // author restated it, as in \"Oven: 180 C (350 F).\"\n if (\n first.cued &&\n !run.readings[1] &&\n !(prefix || SETTING.test(before) || exec(OVEN_AFTER, text, first.end))\n ) {\n return undefined\n }\n // A marker after a pair, as in \"170°C (340°F) Fan\", is for the whole pair.\n const fanEnd =\n text[end - 1] === ')' && !run.readings.some(({ fan }) => fan)\n ? at(FAN_AFTER, text, end)\n : undefined\n if (fanEnd !== undefined) {\n end = fanEnd\n for (const item of run.readings) {\n item.fan = true\n }\n }\n\n return {\n start: prefix ? start - prefix.length : start,\n end,\n reading,\n alternatives: run.readings.slice(1),\n }\n}\n\n/**\n * Adds the items that follow `end` to `run`, such as \"/170C fan\" or\n * \"(200 degrees C)\", and returns where they stop. Groups don't nest.\n */\nfunction extend(text: string, end: number, run: Run, grouped?: true): number {\n // Where the last reading after a joiner starts, and the run's size before it.\n let owner: [number, number] | undefined\n for (let count = 1; count < MAX_ITEMS; count++) {\n run.clash = false\n // A parenthesized run, such as \"(200 degrees C)\" or \"(160°C fan)\".\n let group: number | undefined\n if (!grouped) {\n const { readings, gas } = run\n const { length } = readings\n const first = itemAt(text, at(OPEN, text, end), run)\n group =\n first === undefined\n ? undefined\n : at(CLOSE, text, extend(text, first, run, true))\n if (group !== undefined) {\n // An unmarked reading that restates a fan reading, as 340°F does in\n // \"170°C fan (340°F)\", is for a fan oven too.\n const fanOwner = readings[length - 1]\n for (const item of readings.slice(length)) {\n const { unit } = item.temperature\n if (\n fanOwner?.fan &&\n near(item.temperature, fanOwner.temperature) &&\n !readings.some(\n (other) => other.fan && other.temperature.unit === unit,\n )\n ) {\n item.fan = true\n }\n }\n } else {\n readings.length = length\n run.gas = gas\n }\n }\n // A parenthesis that clashes with the run restates the reading before it,\n // which is a setting of its own, as in \"190°C (375°F) or 170°C fan\n // (340°F)\", so the run ends before that reading.\n if (run.clash && owner) {\n run.readings.length = owner[1]\n return owner[0]\n }\n const size = run.readings.length\n const next = group ?? itemAt(text, at(SEPARATOR, text, end), run)\n if (next === undefined) {\n break\n }\n owner = run.readings[size] ? [end, size] : undefined\n end = next\n }\n return end\n}\n\n/**\n * A reading with its scale that restates the run's reading for the same kind\n * of oven in the other unit, or is the first for its kind, or a gas mark when\n * the run has none yet.\n */\nfunction itemAt(\n text: string,\n index: number | undefined,\n run: Run,\n): number | undefined {\n if (index === undefined) {\n return undefined\n }\n\n const reading = readingAt(text, index)\n if (reading?.unit) {\n const { value, unit, fan } = reading\n const temperature = { value, unit }\n if (\n run.readings.some(\n (item) =>\n item.fan === fan &&\n (item.temperature.unit === unit ||\n !near(item.temperature, temperature)),\n )\n ) {\n run.clash = true\n return undefined\n }\n run.readings.push({ temperature, fan })\n return reading.end\n }\n\n const end = run.gas ? undefined : at(GAS, text, index)\n if (end !== undefined) {\n run.gas = true\n }\n return end\n}\n\n/**\n * Reads one temperature: an optional fan marker, a number, then a scale, a\n * degree sign, or the word \"degree\" or \"degrees\". `unit` is undefined when\n * only a degree sign or word says it is a temperature.\n */\nfunction readingAt(text: string, index: number): Reading | undefined {\n const marked = at(FAN_BEFORE, text, index)\n const number = exec(NUMBER, text, marked ?? index)\n if (!number) {\n return undefined\n }\n\n const [digits, integer = '', fraction] = number\n const after = number.index + digits.length\n let scale = exec(DEGREES, text, after)\n let name = scale && (scale[1] ?? scale[2] ?? scale[3])\n let cups = false\n if (!scale) {\n scale = exec(SCALE, text, after)\n name = scale?.[2]\n // A lone letter needs two digits, as in \"180C\" and \"350 F\". A C that could\n // be cups needs three digits and, to start a run, a word such as \"to\"\n // before it, so \"2C\", \"12 C bowl\", and \"add 100 c sugar\" stay text.\n cups = name === 'c' || (name === 'C' && Boolean(scale?.[1]))\n if (\n !scale ||\n (name?.length === 1 &&\n (fraction || integer.length < (cups || name === 'f' ? 3 : 2)))\n ) {\n return undefined\n }\n }\n\n const end = scale.index + scale[0].length\n const fanEnd = at(FAN_AFTER, text, end) ?? at(FAN_GROUP, text, end)\n\n return {\n value: Number(digits),\n unit: name ? (/^[cс℃]/i.test(name) ? 'C' : 'F') : undefined,\n fan: marked !== undefined || fanEnd !== undefined,\n cued: !name || cups,\n end: fanEnd ?? end,\n }\n}\n\nfunction near(a: Temperature, b: Temperature): boolean {\n return Math.abs(celsius(a) - celsius(b)) <= OVEN_STEP\n}\n\nfunction celsius({ value, unit }: Temperature): number {\n return unit === 'C' ? value : (value - 32) / 1.8\n}\n\nfunction exec(\n pattern: RegExp,\n text: string,\n index: number,\n): RegExpExecArray | null {\n pattern.lastIndex = index\n return pattern.exec(text)\n}\n\n/** Where sticky `pattern` ends when it matches at `index`. */\nfunction at(pattern: RegExp, text: string, index: number): number | undefined {\n return exec(pattern, text, index) ? pattern.lastIndex : undefined\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/steps/temperature.ts"],"sourcesContent":["import type { Temperature, TemperatureUnit } from '../units/temperature'\nimport type { TemperatureReading } from './segment'\n\ntype TemperatureMatch = {\n start: number\n end: number\n reading: TemperatureReading\n alternatives: TemperatureReading[]\n}\n\ntype Reading = {\n value: number\n unit: TemperatureUnit | undefined\n fan: boolean\n /** True when it counts only after a word such as \"at\", as \"350°\" and \"180 C\" do. */\n cued: boolean\n end: number\n}\n\n/**\n * The readings in a run, first included, and whether a gas mark was among\n * them. Readings for the same kind of oven are one setting written in both\n * units, so \"350°F or 375°F\" and \"350°F or 200°C\" are separate settings.\n * `clash` is set when a reading was turned away for that reason.\n */\ntype Run = { readings: TemperatureReading[]; gas: boolean; clash?: boolean }\n\n// Every quantifier is bounded, lookbehind reads at most LOOKBACK characters,\n// and a run holds at most MAX_ITEMS items, so each call does constant work and\n// a scan stays linear in the text length.\nconst LOOKBACK = 24\nconst MAX_ITEMS = 6\n\n// The lookahead never changes a reading, since no scale starts with a digit,\n// \".\" or \",\". It's there for speed: it turns a longer number away before\n// DEGREES and SCALE are tried.\nconst NUMBER = /(\\d{1,4})(\\.\\d{1,2})?(?![.,]?\\d)/y\n// Every word boundary in this file except LETTER, below, treats a Latin or\n// Cyrillic letter, a combining mark, a digit, or \"_\" as continuing a word,\n// since those scripts write the units and separate words with spaces:\n// \"200 fèves\" isn't 200 °F. Chinese, Japanese, and Korean attach words and\n// particles straight to a number or unit, as in \"45°C/113°F的温水\" and\n// \"180°C에서\", so their letters end a word. So do Greek letters, since Greek\n// writes the unit in its own letters: \"180°Cγια\" is missing a space. So does\n// U+0345, a combining mark that only sits on a Greek vowel. Under \"/iu\" the\n// Greek iotas fold onto it and match \"\\p{M}\", so each of these boundaries\n// lets \"ι\" end a word explicitly. \"º\" and \"ª\" end a unit letter too, as in\n// \"180Cº\". The unit may be a Cyrillic \"С\", as in \"180 °С\".\nconst DEGREES =\n /(?:-|\\s{0,3})(?:([℃℉])|(?:[°º˚]|degrees?(?!(?!ι)[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_]))(?:\\s{0,3}(?:(cel[cs]ius|centigrade|fahrenheit|[cfс])(?!(?![ºªι])[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])|\\(([cf])\\)))?)/iuy\nconst SCALE =\n /(\\s{0,3})(cel[cs]ius|centigrade|fahrenheit|[cf])(?!(?![ºªι])[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])(?:°|\\s{1,3}degrees?(?!(?!ι)[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_]))?/iuy\nconst FAN_AFTER =\n /(?:\\s{1,3}|-)(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)(?!(?!ι)[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])/iuy\nconst FAN_GROUP =\n /\\s{0,3}\\(\\s{0,2}(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\s{0,2}\\)/iy\nconst FAN_BEFORE =\n /(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\s{1,3}/iy\nconst FAN_PREFIX =\n /(?:^|ι|[^\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])((?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\\s{1,3})$/iu\nconst GAS =\n /(?:gas(?:\\s{1,3}mark)?|mark)\\s{0,3}(?:\\d{1,2}(?:[-/\\u2013]\\d{1,2})?|[¼½¾])(?!(?!ι)[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])/iuy\nconst SEPARATOR = /\\s{0,3}(?:(?:,?\\s{0,3}or(?=\\s)|[-/,\\u2013\\u2014])\\s{0,3})?/iy\n// A parenthesis may hedge what it restates, as in \"350°F (about 180°C)\".\nconst OPEN =\n /\\s{0,3}\\(\\s{0,2}(?:(?:about|around|roughly|approx(?:imately|\\.)?|ca\\.|i\\.e\\.,?|or)\\s{1,3}|[~≈]\\s{0,2})?/iy\nconst CLOSE = /\\s{0,2}\\)/y\n// Readings this close are one setting written in both units: 170°C is 338°F,\n// and recipes round it to 325°F or 350°F.\nconst OVEN_STEP = 10\n\n// A number that takes its scale from a later one, as 350 does in \"350–375°F\",\n// \"350 to 375 degrees F\", and \"350° (175°C)\". The later one stays text too,\n// so a converter never rewrites one end of a range.\nconst SHARED =\n /\\d\\s{0,3}(?:(?:[°º˚]|degrees?)\\s{0,3}\\(|(?:[°º˚]|degrees?)?\\s{0,3}(?:[-\\u2010-\\u2015~/&]|to|and|or))\\s{0,3}$/i\n// A gas mark before \"/\" or \"or\" has its own scale, so the 200C in\n// \"gas 6/200C\" and \"gas mark 6 or 200C\" is read. Other joiners can start a\n// range, as in \"gas 4–5\" and \"gas mark 4 to 5\", so SHARED still applies.\nconst GAS_ALTERNATIVE = /(?:gas|mark)\\s{0,3}\\d{1,2}\\s{0,3}(?:\\/|or)\\s{0,3}$/i\nconst RANGE_AFTER = /\\s{0,3}(?:[-\\u2010-\\u2015~/&]|to|and|or)\\s{0,3}-?\\d/iy\n// A number that continues another, as in \"1.5\" and \"10:30\", isn't a reading,\n// and \"Reduce the heat by 20°F\" is a difference; converting it is wrong.\nconst NOT_A_READING =\n /\\d[.,:]$|(?:^|ι|[^\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])by\\s{1,3}(?:(?:about|around|roughly|approximately|another|an?|further|additional|extra)\\s{1,3}){0,3}$/iu\n// Words that make a bare \"350°\" or \"350 degrees\" an oven setting rather than\n// an angle, as in \"rotate the pan 180 degrees\", and \"180 C\" a temperature\n// rather than cups, as in \"add 100 c sugar\".\nconst SETTING =\n /(?:^|ι|[^\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])(?:at|to|of|be|is|until|oven|bake|roast|reads?|reach(?:es)?|registers?|hits?)\\s{1,3}(?:(?:about|around|roughly|approximately)\\s{1,3})?$/iu\nconst OVEN_AFTER =\n /\\s{1,3}oven(?!(?!ι)[\\p{sc=Latin}\\p{sc=Cyrillic}\\p{M}\\d_])/iuy\n\nconst SIGN = /[-\\u2212]/\nconst SIGN_BEFORE = /[\\s(]/\n// ASCII only: Chinese puts numbers right after words, as in \"预热至200°C\".\nconst LETTER = /[a-z]/i\n\n/**\n * Matches a temperature, or a run of readings that state one setting several\n * ways, whose first number starts at `digit`. Text before `floor` belongs to\n * an earlier segment and is never included.\n */\nexport function matchTemperature(\n text: string,\n digit: number,\n floor: number,\n assumed: TemperatureUnit | undefined,\n): TemperatureMatch | undefined {\n // Past the start of the text, `charAt` gives '' where an index would read a\n // key set on `Object.prototype`.\n const negative =\n digit > floor &&\n SIGN.test(text[digit - 1] ?? '') &&\n SIGN_BEFORE.test(text.charAt(digit - 2) || ' ')\n const start = negative ? digit - 1 : digit\n const before = text.slice(Math.max(floor, start - LOOKBACK), start)\n if (\n LETTER.test(text.charAt(start - 1)) ||\n NOT_A_READING.test(before) ||\n (SHARED.test(before) && !GAS_ALTERNATIVE.test(before))\n ) {\n return undefined\n }\n\n const first = readingAt(text, digit)\n if (!first) {\n return undefined\n }\n\n const prefix = FAN_PREFIX.exec(before)?.[1]\n const unit = first.unit ?? assumed\n if (!unit || (!first.unit && exec(RANGE_AFTER, text, first.end))) {\n return undefined\n }\n\n const reading: TemperatureReading = {\n temperature: {\n value: negative && first.value ? -first.value : first.value,\n unit,\n },\n fan: Boolean(prefix) || first.fan,\n }\n const run: Run = { readings: [reading], gas: false }\n let end = extend(text, first.end, run)\n // A reading that needs a cue word is a temperature without one when the\n // author restated it, as in \"Oven: 180 C (350 F).\"\n if (\n first.cued &&\n run.readings.length < 2 &&\n !(prefix || SETTING.test(before) || exec(OVEN_AFTER, text, first.end))\n ) {\n return undefined\n }\n // A marker after a pair, as in \"170°C (340°F) Fan\", is for the whole pair.\n const fanEnd =\n text[end - 1] === ')' && !run.readings.some(({ fan }) => fan)\n ? at(FAN_AFTER, text, end)\n : undefined\n if (fanEnd !== undefined) {\n end = fanEnd\n for (const item of run.readings) {\n item.fan = true\n }\n }\n\n return {\n start: prefix ? start - prefix.length : start,\n end,\n reading,\n alternatives: run.readings.slice(1),\n }\n}\n\n/**\n * Adds the items that follow `end` to `run`, such as \"/170C fan\" or\n * \"(200 degrees C)\", and returns where they stop. Groups don't nest.\n */\nfunction extend(text: string, end: number, run: Run, grouped?: true): number {\n // Where the last reading after a joiner starts, and the run's size before it.\n let owner: [number, number] | undefined\n for (let count = 1; count < MAX_ITEMS; count++) {\n run.clash = false\n let group: number | undefined\n if (!grouped) {\n const { readings, gas } = run\n const { length } = readings\n const first = itemAt(text, at(OPEN, text, end), run)\n group =\n first === undefined\n ? undefined\n : at(CLOSE, text, extend(text, first, run, true))\n if (group !== undefined) {\n // An unmarked reading that restates a fan reading, as 340°F does in\n // \"170°C fan (340°F)\", is for a fan oven too.\n const fanOwner = readings[length - 1]\n for (const item of readings.slice(length)) {\n const { unit } = item.temperature\n if (\n fanOwner?.fan &&\n near(item.temperature, fanOwner.temperature) &&\n !readings.some(\n (other) => other.fan && other.temperature.unit === unit,\n )\n ) {\n item.fan = true\n }\n }\n } else {\n readings.length = length\n run.gas = gas\n }\n }\n // A parenthesis that clashes with the run restates the reading before it,\n // which is a setting of its own, as in \"190°C (375°F) or 170°C fan\n // (340°F)\", so the run ends before that reading.\n if (run.clash && owner) {\n run.readings.length = owner[1]\n return owner[0]\n }\n const size = run.readings.length\n const next = group ?? itemAt(text, at(SEPARATOR, text, end), run)\n if (next === undefined) {\n break\n }\n owner = run.readings.length > size ? [end, size] : undefined\n end = next\n }\n return end\n}\n\n/**\n * A reading with its scale that restates the run's reading for the same kind\n * of oven in the other unit, or is the first for its kind, or a gas mark when\n * the run has none yet.\n */\nfunction itemAt(\n text: string,\n index: number | undefined,\n run: Run,\n): number | undefined {\n if (index === undefined) {\n return undefined\n }\n\n const reading = readingAt(text, index)\n if (reading?.unit) {\n const { value, unit, fan } = reading\n const temperature = { value, unit }\n if (\n run.readings.some(\n (item) =>\n item.fan === fan &&\n (item.temperature.unit === unit ||\n !near(item.temperature, temperature)),\n )\n ) {\n run.clash = true\n return undefined\n }\n run.readings.push({ temperature, fan })\n return reading.end\n }\n\n const end = run.gas ? undefined : at(GAS, text, index)\n if (end !== undefined) {\n run.gas = true\n }\n return end\n}\n\n/**\n * Reads one temperature: an optional fan marker, a number, then a scale, a\n * degree sign, or the word \"degree\" or \"degrees\". `unit` is undefined when\n * only a degree sign or word says it is a temperature.\n */\nfunction readingAt(text: string, index: number): Reading | undefined {\n const marked = at(FAN_BEFORE, text, index)\n const number = exec(NUMBER, text, marked ?? index)\n if (!number) {\n return undefined\n }\n\n const [digits, integer = '', fraction] = number\n const after = number.index + digits.length\n let scale = exec(DEGREES, text, after)\n let name = scale && (scale[1] ?? scale[2] ?? scale[3])\n let cups = false\n if (!scale) {\n scale = exec(SCALE, text, after)\n name = scale?.[2]\n // A lone letter needs two digits, as in \"180C\" and \"350 F\". A C that could\n // be cups needs three digits and, to start a run, a word such as \"to\"\n // before it, so \"2C\", \"12 C bowl\", and \"add 100 c sugar\" stay text.\n cups = name === 'c' || (name === 'C' && Boolean(scale?.[1]))\n if (\n !scale ||\n (name?.length === 1 &&\n (fraction || integer.length < (cups || name === 'f' ? 3 : 2)))\n ) {\n return undefined\n }\n }\n\n const end = scale.index + scale[0].length\n const fanEnd = at(FAN_AFTER, text, end) ?? at(FAN_GROUP, text, end)\n\n return {\n value: Number(digits),\n unit: name ? (/^[cс℃]/i.test(name) ? 'C' : 'F') : undefined,\n fan: marked !== undefined || fanEnd !== undefined,\n cued: !name || cups,\n end: fanEnd ?? end,\n }\n}\n\nfunction near(a: Temperature, b: Temperature): boolean {\n return Math.abs(celsius(a) - celsius(b)) <= OVEN_STEP\n}\n\nfunction celsius({ value, unit }: Temperature): number {\n return unit === 'C' ? value : (value - 32) / 1.8\n}\n\nfunction exec(\n pattern: RegExp,\n text: string,\n index: number,\n): RegExpExecArray | null {\n pattern.lastIndex = index\n return pattern.exec(text)\n}\n\n/** Where sticky `pattern` ends when it matches at `index`. */\nfunction at(pattern: RegExp, text: string, index: number): number | undefined {\n return exec(pattern, text, index) ? pattern.lastIndex : undefined\n}\n"],"mappings":";AA8BA,IAAM,WAAW;AACjB,IAAM,YAAY;AAKlB,IAAM,SAAS;AAYf,IAAM,UACJ;AACF,IAAM,QACJ;AACF,IAAM,YACJ;AACF,IAAM,YACJ;AACF,IAAM,aACJ;AACF,IAAM,aACJ;AACF,IAAM,MACJ;AACF,IAAM,YAAY;AAElB,IAAM,OACJ;AACF,IAAM,QAAQ;AAGd,IAAM,YAAY;AAKlB,IAAM,SACJ;AAIF,IAAM,kBAAkB;AACxB,IAAM,cAAc;AAGpB,IAAM,gBACJ;AAIF,IAAM,UACJ;AACF,IAAM,aACJ;AAEF,IAAM,OAAO;AACb,IAAM,cAAc;AAEpB,IAAM,SAAS;AAOR,SAAS,iBACd,MACA,OACA,OACA,SAC8B;AAG9B,QAAM,WACJ,QAAQ,SACR,KAAK,KAAK,KAAK,QAAQ,CAAC,KAAK,EAAE,KAC/B,YAAY,KAAK,KAAK,OAAO,QAAQ,CAAC,KAAK,GAAG;AAChD,QAAM,QAAQ,WAAW,QAAQ,IAAI;AACrC,QAAM,SAAS,KAAK,MAAM,KAAK,IAAI,OAAO,QAAQ,QAAQ,GAAG,KAAK;AAClE,MACE,OAAO,KAAK,KAAK,OAAO,QAAQ,CAAC,CAAC,KAClC,cAAc,KAAK,MAAM,KACxB,OAAO,KAAK,MAAM,KAAK,CAAC,gBAAgB,KAAK,MAAM,GACpD;AACA,WAAO;AAAA,EACT;AAEA,QAAM,QAAQ,UAAU,MAAM,KAAK;AACnC,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,EACT;AAEA,QAAM,SAAS,WAAW,KAAK,MAAM,IAAI,CAAC;AAC1C,QAAM,OAAO,MAAM,QAAQ;AAC3B,MAAI,CAAC,QAAS,CAAC,MAAM,QAAQ,KAAK,aAAa,MAAM,MAAM,GAAG,GAAI;AAChE,WAAO;AAAA,EACT;AAEA,QAAM,UAA8B;AAAA,IAClC,aAAa;AAAA,MACX,OAAO,YAAY,MAAM,QAAQ,CAAC,MAAM,QAAQ,MAAM;AAAA,MACtD;AAAA,IACF;AAAA,IACA,KAAK,QAAQ,MAAM,KAAK,MAAM;AAAA,EAChC;AACA,QAAM,MAAW,EAAE,UAAU,CAAC,OAAO,GAAG,KAAK,MAAM;AACnD,MAAI,MAAM,OAAO,MAAM,MAAM,KAAK,GAAG;AAGrC,MACE,MAAM,QACN,IAAI,SAAS,SAAS,KACtB,EAAE,UAAU,QAAQ,KAAK,MAAM,KAAK,KAAK,YAAY,MAAM,MAAM,GAAG,IACpE;AACA,WAAO;AAAA,EACT;AAEA,QAAM,SACJ,KAAK,MAAM,CAAC,MAAM,OAAO,CAAC,IAAI,SAAS,KAAK,CAAC,EAAE,IAAI,MAAM,GAAG,IACxD,GAAG,WAAW,MAAM,GAAG,IACvB;AACN,MAAI,WAAW,QAAW;AACxB,UAAM;AACN,eAAW,QAAQ,IAAI,UAAU;AAC/B,WAAK,MAAM;AAAA,IACb;AAAA,EACF;AAEA,SAAO;AAAA,IACL,OAAO,SAAS,QAAQ,OAAO,SAAS;AAAA,IACxC;AAAA,IACA;AAAA,IACA,cAAc,IAAI,SAAS,MAAM,CAAC;AAAA,EACpC;AACF;AAMA,SAAS,OAAO,MAAc,KAAa,KAAU,SAAwB;AAE3E,MAAI;AACJ,WAAS,QAAQ,GAAG,QAAQ,WAAW,SAAS;AAC9C,QAAI,QAAQ;AACZ,QAAI;AACJ,QAAI,CAAC,SAAS;AACZ,YAAM,EAAE,UAAU,IAAI,IAAI;AAC1B,YAAM,EAAE,OAAO,IAAI;AACnB,YAAM,QAAQ,OAAO,MAAM,GAAG,MAAM,MAAM,GAAG,GAAG,GAAG;AACnD,cACE,UAAU,SACN,SACA,GAAG,OAAO,MAAM,OAAO,MAAM,OAAO,KAAK,IAAI,CAAC;AACpD,UAAI,UAAU,QAAW;AAGvB,cAAM,WAAW,SAAS,SAAS,CAAC;AACpC,mBAAW,QAAQ,SAAS,MAAM,MAAM,GAAG;AACzC,gBAAM,EAAE,KAAK,IAAI,KAAK;AACtB,cACE,UAAU,OACV,KAAK,KAAK,aAAa,SAAS,WAAW,KAC3C,CAAC,SAAS;AAAA,YACR,CAAC,UAAU,MAAM,OAAO,MAAM,YAAY,SAAS;AAAA,UACrD,GACA;AACA,iBAAK,MAAM;AAAA,UACb;AAAA,QACF;AAAA,MACF,OAAO;AACL,iBAAS,SAAS;AAClB,YAAI,MAAM;AAAA,MACZ;AAAA,IACF;AAIA,QAAI,IAAI,SAAS,OAAO;AACtB,UAAI,SAAS,SAAS,MAAM,CAAC;AAC7B,aAAO,MAAM,CAAC;AAAA,IAChB;AACA,UAAM,OAAO,IAAI,SAAS;AAC1B,UAAM,OAAO,SAAS,OAAO,MAAM,GAAG,WAAW,MAAM,GAAG,GAAG,GAAG;AAChE,QAAI,SAAS,QAAW;AACtB;AAAA,IACF;AACA,YAAQ,IAAI,SAAS,SAAS,OAAO,CAAC,KAAK,IAAI,IAAI;AACnD,UAAM;AAAA,EACR;AACA,SAAO;AACT;AAOA,SAAS,OACP,MACA,OACA,KACoB;AACpB,MAAI,UAAU,QAAW;AACvB,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,UAAU,MAAM,KAAK;AACrC,MAAI,SAAS,MAAM;AACjB,UAAM,EAAE,OAAO,MAAM,IAAI,IAAI;AAC7B,UAAM,cAAc,EAAE,OAAO,KAAK;AAClC,QACE,IAAI,SAAS;AAAA,MACX,CAAC,SACC,KAAK,QAAQ,QACZ,KAAK,YAAY,SAAS,QACzB,CAAC,KAAK,KAAK,aAAa,WAAW;AAAA,IACzC,GACA;AACA,UAAI,QAAQ;AACZ,aAAO;AAAA,IACT;AACA,QAAI,SAAS,KAAK,EAAE,aAAa,IAAI,CAAC;AACtC,WAAO,QAAQ;AAAA,EACjB;AAEA,QAAM,MAAM,IAAI,MAAM,SAAY,GAAG,KAAK,MAAM,KAAK;AACrD,MAAI,QAAQ,QAAW;AACrB,QAAI,MAAM;AAAA,EACZ;AACA,SAAO;AACT;AAOA,SAAS,UAAU,MAAc,OAAoC;AACnE,QAAM,SAAS,GAAG,YAAY,MAAM,KAAK;AACzC,QAAM,SAAS,KAAK,QAAQ,MAAM,UAAU,KAAK;AACjD,MAAI,CAAC,QAAQ;AACX,WAAO;AAAA,EACT;AAEA,QAAM,CAAC,QAAQ,UAAU,IAAI,QAAQ,IAAI;AACzC,QAAM,QAAQ,OAAO,QAAQ,OAAO;AACpC,MAAI,QAAQ,KAAK,SAAS,MAAM,KAAK;AACrC,MAAI,OAAO,UAAU,MAAM,CAAC,KAAK,MAAM,CAAC,KAAK,MAAM,CAAC;AACpD,MAAI,OAAO;AACX,MAAI,CAAC,OAAO;AACV,YAAQ,KAAK,OAAO,MAAM,KAAK;AAC/B,WAAO,QAAQ,CAAC;AAIhB,WAAO,SAAS,OAAQ,SAAS,OAAO,QAAQ,QAAQ,CAAC,CAAC;AAC1D,QACE,CAAC,SACA,MAAM,WAAW,MACf,YAAY,QAAQ,UAAU,QAAQ,SAAS,MAAM,IAAI,KAC5D;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,MAAM,MAAM,QAAQ,MAAM,CAAC,EAAE;AACnC,QAAM,SAAS,GAAG,WAAW,MAAM,GAAG,KAAK,GAAG,WAAW,MAAM,GAAG;AAElE,SAAO;AAAA,IACL,OAAO,OAAO,MAAM;AAAA,IACpB,MAAM,OAAQ,UAAU,KAAK,IAAI,IAAI,MAAM,MAAO;AAAA,IAClD,KAAK,WAAW,UAAa,WAAW;AAAA,IACxC,MAAM,CAAC,QAAQ;AAAA,IACf,KAAK,UAAU;AAAA,EACjB;AACF;AAEA,SAAS,KAAK,GAAgB,GAAyB;AACrD,SAAO,KAAK,IAAI,QAAQ,CAAC,IAAI,QAAQ,CAAC,CAAC,KAAK;AAC9C;AAEA,SAAS,QAAQ,EAAE,OAAO,KAAK,GAAwB;AACrD,SAAO,SAAS,MAAM,SAAS,QAAQ,MAAM;AAC/C;AAEA,SAAS,KACP,SACA,MACA,OACwB;AACxB,UAAQ,YAAY;AACpB,SAAO,QAAQ,KAAK,IAAI;AAC1B;AAGA,SAAS,GAAG,SAAiB,MAAc,OAAmC;AAC5E,SAAO,KAAK,SAAS,MAAM,KAAK,IAAI,QAAQ,YAAY;AAC1D;","names":[]}
@@ -12,7 +12,7 @@ declare class RecipeValidationError extends RecipeBookError {
12
12
  * @param subject - What was validated, shown as given at the start of the
13
13
  * message, e.g. `'"Buttermilk pancakes"'`. Recipe Book's own errors quote
14
14
  * the recipe's name or the input text like a JSON string, cut with "…"
15
- * when longer than 60 characters.
15
+ * when longer than 60 UTF-16 code units.
16
16
  */
17
17
  constructor(code: ValidationErrorCode, issues: readonly RecipeIssue[], subject?: string);
18
18
  }
@@ -602,8 +602,9 @@ type Nutrition = {
602
602
  /**
603
603
  * A normalized recipe, as returned by `parseRecipe`. Plain JSON data: it can be
604
604
  * cached, stored, or passed from a Server Component to a Client Component.
605
- * Every `Recipe` is also a valid `RecipeInput`, and parsing one returns an
606
- * equal recipe.
605
+ * A `Recipe` whose quantity, servings, and yield amounts are from 1e-300 to
606
+ * 1e290, as in every recipe that `parseRecipe` or `fromJsonLd` returns, is also
607
+ * a valid `RecipeInput`, and parsing it returns an equal recipe.
607
608
  */
608
609
  type Recipe = {
609
610
  id: string;
@@ -12,7 +12,7 @@ declare class RecipeValidationError extends RecipeBookError {
12
12
  * @param subject - What was validated, shown as given at the start of the
13
13
  * message, e.g. `'"Buttermilk pancakes"'`. Recipe Book's own errors quote
14
14
  * the recipe's name or the input text like a JSON string, cut with "…"
15
- * when longer than 60 characters.
15
+ * when longer than 60 UTF-16 code units.
16
16
  */
17
17
  constructor(code: ValidationErrorCode, issues: readonly RecipeIssue[], subject?: string);
18
18
  }
@@ -602,8 +602,9 @@ type Nutrition = {
602
602
  /**
603
603
  * A normalized recipe, as returned by `parseRecipe`. Plain JSON data: it can be
604
604
  * cached, stored, or passed from a Server Component to a Client Component.
605
- * Every `Recipe` is also a valid `RecipeInput`, and parsing one returns an
606
- * equal recipe.
605
+ * A `Recipe` whose quantity, servings, and yield amounts are from 1e-300 to
606
+ * 1e290, as in every recipe that `parseRecipe` or `fromJsonLd` returns, is also
607
+ * a valid `RecipeInput`, and parsing it returns an equal recipe.
607
608
  */
608
609
  type Recipe = {
609
610
  id: string;
@@ -1,6 +1,10 @@
1
1
  // src/units/convert.ts
2
2
  import { RecipeBookError } from "../errors.js";
3
- import { checkQuantity, mapAmount } from "../quantity/amount.js";
3
+ import {
4
+ checkQuantity,
5
+ checkResult,
6
+ mapAmount
7
+ } from "../quantity/amount.js";
4
8
  import { getUnit, unknownUnitError } from "./lookup.js";
5
9
  import {
6
10
  lookupUnit
@@ -19,9 +23,9 @@ function convert(quantity, to) {
19
23
  const fromBase = source.toBase;
20
24
  const toBase = target.toBase;
21
25
  return {
22
- amount: mapAmount(
23
- amount,
24
- (value) => value * fromBase / toBase
26
+ amount: checkResult(
27
+ "convert",
28
+ mapAmount(amount, (value) => value * fromBase / toBase)
25
29
  ),
26
30
  unit: to
27
31
  };
@@ -35,12 +39,16 @@ function canConvert(from, to) {
35
39
  return source.id === target.id || source.toBase !== void 0 && source.dimension === target.dimension;
36
40
  }
37
41
  function toSystem(quantity, system) {
38
- checkQuantity("toSystem", quantity);
42
+ const amount = checkQuantity("toSystem", quantity);
39
43
  checkSystem("toSystem", system);
40
- if (quantity.unit !== void 0 && !lookupUnit(quantity.unit)) {
41
- throw unknownUnitError(quantity.unit);
44
+ const { unit } = quantity;
45
+ if (unit !== void 0 && !lookupUnit(unit)) {
46
+ throw unknownUnitError(unit);
42
47
  }
43
- return inSystem(quantity, system);
48
+ const checked = { amount, unit };
49
+ const result = inSystem(checked, system);
50
+ checkResult("toSystem", result.amount);
51
+ return result === checked ? quantity : result;
44
52
  }
45
53
  function incompatibleUnits(source, target) {
46
54
  const example = target.dimension === "mass" ? "120 g" : target.dimension === "volume" ? "250 ml" : `1 ${target.id}`;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/units/convert.ts"],"sourcesContent":["import { RecipeBookError } from '../errors'\nimport { checkQuantity, mapAmount, type SameAmount } from '../quantity/amount'\nimport type { Amount, Quantity } from '../quantity/types'\nimport { getUnit, unknownUnitError } from './lookup'\nimport {\n lookupUnit,\n type System,\n type UnitDefinition,\n type UnitId,\n} from './registry'\nimport { checkSystem, inSystem } from './system'\n\n/**\n * Converts a quantity to another unit of the same dimension. Both ends of a\n * range convert. Nothing is rounded, so the amount can be off in its last\n * digit: 5 tbsp is 14.999999999999998 tsp.\n *\n * @throws {RecipeBookError} `incompatible_units` across dimensions (cup to g),\n * between a count unit and a different unit, or for a bare count. Check with\n * {@link canConvert} first when the units come from data.\n * @throws {RecipeBookError} `unknown_unit` when either unit is not a unit id.\n * @throws {RecipeBookError} `invalid_argument` when `quantity` isn't a quantity object, the amount is not a finite number greater than 0 or a range of them, a range's `min` isn't less than its `max`, or the quantity has `max` set, as a `Servings` or `Yield` has.\n *\n * @example\n * convert({ amount: 1, unit: 'cup' }, 'ml') // { amount: 236.5882365, unit: 'ml' }\n * convert({ amount: { min: 2, max: 3 }, unit: 'tbsp' }, 'tsp') // { amount: { min: 6, max: 9 }, unit: 'tsp' }\n */\nexport function convert<A extends Amount, To extends UnitId>(\n quantity: Quantity & { amount: A },\n to: To,\n): { amount: SameAmount<A>; unit: To } {\n const amount = checkQuantity('convert', quantity)\n const target = getUnit(to)\n const source =\n quantity.unit === undefined ? undefined : getUnit(quantity.unit)\n\n if (source?.id === target.id) {\n return { amount: amount as SameAmount<A>, unit: to }\n }\n if (\n source?.toBase === undefined ||\n target.toBase === undefined ||\n source.dimension !== target.dimension\n ) {\n throw incompatibleUnits(source, target)\n }\n\n const fromBase = source.toBase\n const toBase = target.toBase\n return {\n amount: mapAmount(\n amount,\n (value) => (value * fromBase) / toBase,\n ) as SameAmount<A>,\n unit: to,\n }\n}\n\n/**\n * True when {@link convert} can convert between the two units: same unit, or\n * mass to mass, or volume to volume. Never throws, so it is safe on untrusted data.\n *\n * @example\n * canConvert('cup', 'ml') // true\n * canConvert('cup', 'g') // false\n * canConvert(undefined, 'g') // false: a bare count has no size\n */\nexport function canConvert(from: UnitId | undefined, to: UnitId): boolean {\n const source = lookupUnit(from)\n const target = lookupUnit(to)\n\n if (!source || !target) {\n return false\n }\n\n // Only count units lack `toBase`, so a target with the source's dimension has one.\n return (\n source.id === target.id ||\n (source.toBase !== undefined && source.dimension === target.dimension)\n )\n}\n\n/**\n * Expresses a quantity in a measurement system, choosing the display unit\n * from that system's ladder: g and kg, ml and l, oz and lb, or tsp, tbsp, and\n * cup. Nothing is rounded, so the amount can be off in its last digit, as with\n * {@link convert}.\n *\n * Returns the quantity unchanged when its unit is already native to `system`\n * (tsp and tbsp are native to both), when it is a count unit, or when it has\n * no unit.\n *\n * @throws {RecipeBookError} `invalid_argument` for an unknown system, an invalid amount, or a quantity with `max` set, as a `Servings` or `Yield` has.\n * @throws {RecipeBookError} `unknown_unit` when the unit is not a unit id.\n *\n * @example\n * toSystem({ amount: 250, unit: 'g' }, 'us') // { amount: 8.818…, unit: 'oz' }\n * toSystem({ amount: 0.5, unit: 'cup' }, 'metric') // { amount: 118.29…, unit: 'ml' }\n * toSystem({ amount: 3, unit: 'tsp' }, 'metric') // unchanged\n */\nexport function toSystem<A extends Amount>(\n quantity: Quantity & { amount: A },\n system: System,\n): { amount: SameAmount<A>; unit?: UnitId | undefined } {\n checkQuantity('toSystem', quantity)\n checkSystem('toSystem', system)\n\n if (quantity.unit !== undefined && !lookupUnit(quantity.unit)) {\n throw unknownUnitError(quantity.unit)\n }\n\n return inSystem(quantity, system) as {\n amount: SameAmount<A>\n unit?: UnitId | undefined\n }\n}\n\nfunction incompatibleUnits(\n source: UnitDefinition | undefined,\n target: UnitDefinition,\n): RecipeBookError {\n const example =\n target.dimension === 'mass'\n ? '120 g'\n : target.dimension === 'volume'\n ? '250 ml'\n : `1 ${target.id}`\n const fix = `Add an equivalent to the ingredient, e.g. equivalents: [\"${example}\"].`\n\n if (!source) {\n return new RecipeBookError(\n 'incompatible_units',\n `Cannot convert a quantity without a unit to ${target.id}; a bare count such as \"2 eggs\" has no fixed size. ${fix}`,\n )\n }\n\n const countUnit = [source, target].find((unit) => unit.toBase === undefined)\n if (countUnit) {\n return new RecipeBookError(\n 'incompatible_units',\n `Cannot convert ${source.id} to ${target.id}; ${countUnit.id} is a count unit with no fixed size. ${fix}`,\n )\n }\n\n return new RecipeBookError(\n 'incompatible_units',\n `Cannot convert ${source.id} (${source.dimension}) to ${target.id} (${target.dimension}). ${fix}`,\n )\n}\n"],"mappings":";AAAA,SAAS,uBAAuB;AAChC,SAAS,eAAe,iBAAkC;AAE1D,SAAS,SAAS,wBAAwB;AAC1C;AAAA,EACE;AAAA,OAIK;AACP,SAAS,aAAa,gBAAgB;AAiB/B,SAAS,QACd,UACA,IACqC;AACrC,QAAM,SAAS,cAAc,WAAW,QAAQ;AAChD,QAAM,SAAS,QAAQ,EAAE;AACzB,QAAM,SACJ,SAAS,SAAS,SAAY,SAAY,QAAQ,SAAS,IAAI;AAEjE,MAAI,QAAQ,OAAO,OAAO,IAAI;AAC5B,WAAO,EAAE,QAAiC,MAAM,GAAG;AAAA,EACrD;AACA,MACE,QAAQ,WAAW,UACnB,OAAO,WAAW,UAClB,OAAO,cAAc,OAAO,WAC5B;AACA,UAAM,kBAAkB,QAAQ,MAAM;AAAA,EACxC;AAEA,QAAM,WAAW,OAAO;AACxB,QAAM,SAAS,OAAO;AACtB,SAAO;AAAA,IACL,QAAQ;AAAA,MACN;AAAA,MACA,CAAC,UAAW,QAAQ,WAAY;AAAA,IAClC;AAAA,IACA,MAAM;AAAA,EACR;AACF;AAWO,SAAS,WAAW,MAA0B,IAAqB;AACxE,QAAM,SAAS,WAAW,IAAI;AAC9B,QAAM,SAAS,WAAW,EAAE;AAE5B,MAAI,CAAC,UAAU,CAAC,QAAQ;AACtB,WAAO;AAAA,EACT;AAGA,SACE,OAAO,OAAO,OAAO,MACpB,OAAO,WAAW,UAAa,OAAO,cAAc,OAAO;AAEhE;AAoBO,SAAS,SACd,UACA,QACsD;AACtD,gBAAc,YAAY,QAAQ;AAClC,cAAY,YAAY,MAAM;AAE9B,MAAI,SAAS,SAAS,UAAa,CAAC,WAAW,SAAS,IAAI,GAAG;AAC7D,UAAM,iBAAiB,SAAS,IAAI;AAAA,EACtC;AAEA,SAAO,SAAS,UAAU,MAAM;AAIlC;AAEA,SAAS,kBACP,QACA,QACiB;AACjB,QAAM,UACJ,OAAO,cAAc,SACjB,UACA,OAAO,cAAc,WACnB,WACA,KAAK,OAAO,EAAE;AACtB,QAAM,MAAM,4DAA4D,OAAO;AAE/E,MAAI,CAAC,QAAQ;AACX,WAAO,IAAI;AAAA,MACT;AAAA,MACA,+CAA+C,OAAO,EAAE,sDAAsD,GAAG;AAAA,IACnH;AAAA,EACF;AAEA,QAAM,YAAY,CAAC,QAAQ,MAAM,EAAE,KAAK,CAAC,SAAS,KAAK,WAAW,MAAS;AAC3E,MAAI,WAAW;AACb,WAAO,IAAI;AAAA,MACT;AAAA,MACA,kBAAkB,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,UAAU,EAAE,wCAAwC,GAAG;AAAA,IACzG;AAAA,EACF;AAEA,SAAO,IAAI;AAAA,IACT;AAAA,IACA,kBAAkB,OAAO,EAAE,KAAK,OAAO,SAAS,QAAQ,OAAO,EAAE,KAAK,OAAO,SAAS,MAAM,GAAG;AAAA,EACjG;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/units/convert.ts"],"sourcesContent":["import { RecipeBookError } from '../errors'\nimport {\n checkQuantity,\n checkResult,\n mapAmount,\n type SameAmount,\n} from '../quantity/amount'\nimport type { Amount, Quantity } from '../quantity/types'\nimport { getUnit, unknownUnitError } from './lookup'\nimport {\n lookupUnit,\n type System,\n type UnitDefinition,\n type UnitId,\n} from './registry'\nimport { checkSystem, inSystem } from './system'\n\n/**\n * Converts a quantity to another unit of the same dimension. Both ends of a\n * range convert. Nothing is rounded, so the amount can be off in its last\n * digit: 5 tbsp is 14.999999999999998 tsp.\n *\n * @throws {RecipeBookError} `incompatible_units` across dimensions (cup to g),\n * between a count unit and a different unit, or for a bare count. Check with\n * {@link canConvert} first when the units come from data.\n * @throws {RecipeBookError} `unknown_unit` when either unit is not a unit id.\n * @throws {RecipeBookError} `invalid_argument` when `quantity` isn't a quantity object, the amount is not a finite number greater than 0 or a range of them, a range's `min` isn't less than its `max`, the quantity has `max` set, as a `Servings` or `Yield` has, or the result overflows to `Infinity` or underflows to 0, which only an amount above about 1e300 or below about 1e-300 can do.\n *\n * @example\n * convert({ amount: 1, unit: 'cup' }, 'ml') // { amount: 236.5882365, unit: 'ml' }\n * convert({ amount: { min: 2, max: 3 }, unit: 'tbsp' }, 'tsp') // { amount: { min: 6, max: 9 }, unit: 'tsp' }\n */\nexport function convert<A extends Amount, To extends UnitId>(\n quantity: Quantity & { amount: A },\n to: To,\n): { amount: SameAmount<A>; unit: To } {\n const amount = checkQuantity('convert', quantity)\n const target = getUnit(to)\n const source =\n quantity.unit === undefined ? undefined : getUnit(quantity.unit)\n\n if (source?.id === target.id) {\n return { amount: amount as SameAmount<A>, unit: to }\n }\n if (\n source?.toBase === undefined ||\n target.toBase === undefined ||\n source.dimension !== target.dimension\n ) {\n throw incompatibleUnits(source, target)\n }\n\n const fromBase = source.toBase\n const toBase = target.toBase\n return {\n amount: checkResult(\n 'convert',\n mapAmount(amount, (value) => (value * fromBase) / toBase),\n ) as SameAmount<A>,\n unit: to,\n }\n}\n\n/**\n * True when {@link convert} can convert between the two units: same unit, or\n * mass to mass, or volume to volume. Never throws, so it is safe on untrusted data.\n *\n * @example\n * canConvert('cup', 'ml') // true\n * canConvert('cup', 'g') // false\n * canConvert(undefined, 'g') // false: a bare count has no size\n */\nexport function canConvert(from: UnitId | undefined, to: UnitId): boolean {\n const source = lookupUnit(from)\n const target = lookupUnit(to)\n\n if (!source || !target) {\n return false\n }\n\n // Only count units lack `toBase`, so a target with the source's dimension has one.\n return (\n source.id === target.id ||\n (source.toBase !== undefined && source.dimension === target.dimension)\n )\n}\n\n/**\n * Expresses a quantity in a measurement system, choosing the display unit\n * from that system's ladder: g and kg, ml and l, oz and lb, or tsp, tbsp, and\n * cup. Nothing is rounded, so the amount can be off in its last digit, as with\n * {@link convert}.\n *\n * Returns the quantity unchanged when its unit is already native to `system`\n * (tsp and tbsp are native to both), when it is a count unit, or when it has\n * no unit.\n *\n * @throws {RecipeBookError} `invalid_argument` for an unknown system, an invalid amount, a quantity with `max` set, as a `Servings` or `Yield` has, or a result that overflows to `Infinity` or underflows to 0, which only an amount above about 1e300 or below about 1e-300 can do.\n * @throws {RecipeBookError} `unknown_unit` when the unit is not a unit id.\n *\n * @example\n * toSystem({ amount: 250, unit: 'g' }, 'us') // { amount: 8.818…, unit: 'oz' }\n * toSystem({ amount: 0.5, unit: 'cup' }, 'metric') // { amount: 118.29…, unit: 'ml' }\n * toSystem({ amount: 3, unit: 'tsp' }, 'metric') // unchanged\n */\nexport function toSystem<A extends Amount>(\n quantity: Quantity & { amount: A },\n system: System,\n): { amount: SameAmount<A>; unit?: UnitId | undefined } {\n const amount = checkQuantity('toSystem', quantity)\n checkSystem('toSystem', system)\n const { unit } = quantity\n\n if (unit !== undefined && !lookupUnit(unit)) {\n throw unknownUnitError(unit)\n }\n\n const checked = { amount, unit }\n const result = inSystem(checked, system)\n checkResult('toSystem', result.amount)\n return (result === checked ? quantity : result) as {\n amount: SameAmount<A>\n unit?: UnitId | undefined\n }\n}\n\nfunction incompatibleUnits(\n source: UnitDefinition | undefined,\n target: UnitDefinition,\n): RecipeBookError {\n const example =\n target.dimension === 'mass'\n ? '120 g'\n : target.dimension === 'volume'\n ? '250 ml'\n : `1 ${target.id}`\n const fix = `Add an equivalent to the ingredient, e.g. equivalents: [\"${example}\"].`\n\n if (!source) {\n return new RecipeBookError(\n 'incompatible_units',\n `Cannot convert a quantity without a unit to ${target.id}; a bare count such as \"2 eggs\" has no fixed size. ${fix}`,\n )\n }\n\n const countUnit = [source, target].find((unit) => unit.toBase === undefined)\n if (countUnit) {\n return new RecipeBookError(\n 'incompatible_units',\n `Cannot convert ${source.id} to ${target.id}; ${countUnit.id} is a count unit with no fixed size. ${fix}`,\n )\n }\n\n return new RecipeBookError(\n 'incompatible_units',\n `Cannot convert ${source.id} (${source.dimension}) to ${target.id} (${target.dimension}). ${fix}`,\n )\n}\n"],"mappings":";AAAA,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAEP,SAAS,SAAS,wBAAwB;AAC1C;AAAA,EACE;AAAA,OAIK;AACP,SAAS,aAAa,gBAAgB;AAiB/B,SAAS,QACd,UACA,IACqC;AACrC,QAAM,SAAS,cAAc,WAAW,QAAQ;AAChD,QAAM,SAAS,QAAQ,EAAE;AACzB,QAAM,SACJ,SAAS,SAAS,SAAY,SAAY,QAAQ,SAAS,IAAI;AAEjE,MAAI,QAAQ,OAAO,OAAO,IAAI;AAC5B,WAAO,EAAE,QAAiC,MAAM,GAAG;AAAA,EACrD;AACA,MACE,QAAQ,WAAW,UACnB,OAAO,WAAW,UAClB,OAAO,cAAc,OAAO,WAC5B;AACA,UAAM,kBAAkB,QAAQ,MAAM;AAAA,EACxC;AAEA,QAAM,WAAW,OAAO;AACxB,QAAM,SAAS,OAAO;AACtB,SAAO;AAAA,IACL,QAAQ;AAAA,MACN;AAAA,MACA,UAAU,QAAQ,CAAC,UAAW,QAAQ,WAAY,MAAM;AAAA,IAC1D;AAAA,IACA,MAAM;AAAA,EACR;AACF;AAWO,SAAS,WAAW,MAA0B,IAAqB;AACxE,QAAM,SAAS,WAAW,IAAI;AAC9B,QAAM,SAAS,WAAW,EAAE;AAE5B,MAAI,CAAC,UAAU,CAAC,QAAQ;AACtB,WAAO;AAAA,EACT;AAGA,SACE,OAAO,OAAO,OAAO,MACpB,OAAO,WAAW,UAAa,OAAO,cAAc,OAAO;AAEhE;AAoBO,SAAS,SACd,UACA,QACsD;AACtD,QAAM,SAAS,cAAc,YAAY,QAAQ;AACjD,cAAY,YAAY,MAAM;AAC9B,QAAM,EAAE,KAAK,IAAI;AAEjB,MAAI,SAAS,UAAa,CAAC,WAAW,IAAI,GAAG;AAC3C,UAAM,iBAAiB,IAAI;AAAA,EAC7B;AAEA,QAAM,UAAU,EAAE,QAAQ,KAAK;AAC/B,QAAM,SAAS,SAAS,SAAS,MAAM;AACvC,cAAY,YAAY,OAAO,MAAM;AACrC,SAAQ,WAAW,UAAU,WAAW;AAI1C;AAEA,SAAS,kBACP,QACA,QACiB;AACjB,QAAM,UACJ,OAAO,cAAc,SACjB,UACA,OAAO,cAAc,WACnB,WACA,KAAK,OAAO,EAAE;AACtB,QAAM,MAAM,4DAA4D,OAAO;AAE/E,MAAI,CAAC,QAAQ;AACX,WAAO,IAAI;AAAA,MACT;AAAA,MACA,+CAA+C,OAAO,EAAE,sDAAsD,GAAG;AAAA,IACnH;AAAA,EACF;AAEA,QAAM,YAAY,CAAC,QAAQ,MAAM,EAAE,KAAK,CAAC,SAAS,KAAK,WAAW,MAAS;AAC3E,MAAI,WAAW;AACb,WAAO,IAAI;AAAA,MACT;AAAA,MACA,kBAAkB,OAAO,EAAE,OAAO,OAAO,EAAE,KAAK,UAAU,EAAE,wCAAwC,GAAG;AAAA,IACzG;AAAA,EACF;AAEA,SAAO,IAAI;AAAA,IACT;AAAA,IACA,kBAAkB,OAAO,EAAE,KAAK,OAAO,SAAS,QAAQ,OAAO,EAAE,KAAK,OAAO,SAAS,MAAM,GAAG;AAAA,EACjG;AACF;","names":[]}
@@ -20,7 +20,13 @@ function convertTemperature(temperature, to) {
20
20
  if (unit === to) {
21
21
  return { value, unit };
22
22
  }
23
- return to === "C" ? { value: (value - 32) * 5 / 9, unit: "C" } : { value: value * 9 / 5 + 32, unit: "F" };
23
+ const result = to === "C" ? (value - 32) * 5 / 9 : value * 9 / 5 + 32;
24
+ if (!Number.isFinite(result)) {
25
+ throw invalidArgument(
26
+ `the result must be a finite number, but it is ${result}`
27
+ );
28
+ }
29
+ return { value: result, unit: to };
24
30
  }
25
31
  function invalidArgument(detail) {
26
32
  return new RecipeBookError(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/units/temperature.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\n\n/** Degrees Celsius or Fahrenheit. */\nexport type TemperatureUnit = 'C' | 'F'\n\n/**\n * An oven or food temperature. Temperatures are not quantities: converting\n * them is affine, and scaling a recipe never changes them.\n */\nexport type Temperature = { value: number; unit: TemperatureUnit }\n\n/**\n * Converts a temperature between Celsius and Fahrenheit. Nothing is rounded,\n * so the value can be off in its last digits: −18 °C is\n * −0.3999999999999986 °F.\n *\n * @throws {RecipeBookError} `invalid_argument` for a non-finite value or an unknown unit.\n *\n * @example\n * convertTemperature({ value: 350, unit: 'F' }, 'C') // { value: 176.66…, unit: 'C' }\n * convertTemperature({ value: 180, unit: 'C' }, 'F') // { value: 356, unit: 'F' }\n */\nexport function convertTemperature(\n temperature: Temperature,\n to: TemperatureUnit,\n): Temperature {\n const { value, unit } = temperature ?? {}\n\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw invalidArgument(\n `value must be a finite number, received ${describeValue(value)}`,\n )\n }\n if (unit !== 'C' && unit !== 'F') {\n throw invalidArgument(\n `temperature unit must be \"C\" or \"F\", received ${describeValue(unit)}`,\n )\n }\n if (to !== 'C' && to !== 'F') {\n throw invalidArgument(\n `target unit must be \"C\" or \"F\", received ${describeValue(to)}`,\n )\n }\n\n if (unit === to) {\n return { value, unit }\n }\n\n return to === 'C'\n ? { value: ((value - 32) * 5) / 9, unit: 'C' }\n : { value: (value * 9) / 5 + 32, unit: 'F' }\n}\n\nfunction invalidArgument(detail: string): RecipeBookError {\n return new RecipeBookError(\n 'invalid_argument',\n `convertTemperature: ${detail}.`,\n )\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAsBxC,SAAS,mBACd,aACA,IACa;AACb,QAAM,EAAE,OAAO,KAAK,IAAI,eAAe,CAAC;AAExC,MAAI,OAAO,UAAU,YAAY,CAAC,OAAO,SAAS,KAAK,GAAG;AACxD,UAAM;AAAA,MACJ,2CAA2C,cAAc,KAAK,CAAC;AAAA,IACjE;AAAA,EACF;AACA,MAAI,SAAS,OAAO,SAAS,KAAK;AAChC,UAAM;AAAA,MACJ,iDAAiD,cAAc,IAAI,CAAC;AAAA,IACtE;AAAA,EACF;AACA,MAAI,OAAO,OAAO,OAAO,KAAK;AAC5B,UAAM;AAAA,MACJ,4CAA4C,cAAc,EAAE,CAAC;AAAA,IAC/D;AAAA,EACF;AAEA,MAAI,SAAS,IAAI;AACf,WAAO,EAAE,OAAO,KAAK;AAAA,EACvB;AAEA,SAAO,OAAO,MACV,EAAE,QAAS,QAAQ,MAAM,IAAK,GAAG,MAAM,IAAI,IAC3C,EAAE,OAAQ,QAAQ,IAAK,IAAI,IAAI,MAAM,IAAI;AAC/C;AAEA,SAAS,gBAAgB,QAAiC;AACxD,SAAO,IAAI;AAAA,IACT;AAAA,IACA,uBAAuB,MAAM;AAAA,EAC/B;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/units/temperature.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\n\n/** Degrees Celsius or Fahrenheit. */\nexport type TemperatureUnit = 'C' | 'F'\n\n/**\n * An oven or food temperature. Temperatures are not quantities: converting\n * them is affine, and scaling a recipe never changes them.\n */\nexport type Temperature = { value: number; unit: TemperatureUnit }\n\n/**\n * Converts a temperature between Celsius and Fahrenheit. Nothing is rounded,\n * so the value can be off in its last digits: −18 °C is\n * −0.3999999999999986 °F.\n *\n * @throws {RecipeBookError} `invalid_argument` for a non-finite value, an unknown unit, or a result that overflows to `Infinity` or `-Infinity`, which only a value beyond about ±2e307 can do.\n *\n * @example\n * convertTemperature({ value: 350, unit: 'F' }, 'C') // { value: 176.66…, unit: 'C' }\n * convertTemperature({ value: 180, unit: 'C' }, 'F') // { value: 356, unit: 'F' }\n */\nexport function convertTemperature(\n temperature: Temperature,\n to: TemperatureUnit,\n): Temperature {\n const { value, unit } = temperature ?? {}\n\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw invalidArgument(\n `value must be a finite number, received ${describeValue(value)}`,\n )\n }\n if (unit !== 'C' && unit !== 'F') {\n throw invalidArgument(\n `temperature unit must be \"C\" or \"F\", received ${describeValue(unit)}`,\n )\n }\n if (to !== 'C' && to !== 'F') {\n throw invalidArgument(\n `target unit must be \"C\" or \"F\", received ${describeValue(to)}`,\n )\n }\n\n if (unit === to) {\n return { value, unit }\n }\n\n const result = to === 'C' ? ((value - 32) * 5) / 9 : (value * 9) / 5 + 32\n if (!Number.isFinite(result)) {\n throw invalidArgument(\n `the result must be a finite number, but it is ${result}`,\n )\n }\n return { value: result, unit: to }\n}\n\nfunction invalidArgument(detail: string): RecipeBookError {\n return new RecipeBookError(\n 'invalid_argument',\n `convertTemperature: ${detail}.`,\n )\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAsBxC,SAAS,mBACd,aACA,IACa;AACb,QAAM,EAAE,OAAO,KAAK,IAAI,eAAe,CAAC;AAExC,MAAI,OAAO,UAAU,YAAY,CAAC,OAAO,SAAS,KAAK,GAAG;AACxD,UAAM;AAAA,MACJ,2CAA2C,cAAc,KAAK,CAAC;AAAA,IACjE;AAAA,EACF;AACA,MAAI,SAAS,OAAO,SAAS,KAAK;AAChC,UAAM;AAAA,MACJ,iDAAiD,cAAc,IAAI,CAAC;AAAA,IACtE;AAAA,EACF;AACA,MAAI,OAAO,OAAO,OAAO,KAAK;AAC5B,UAAM;AAAA,MACJ,4CAA4C,cAAc,EAAE,CAAC;AAAA,IAC/D;AAAA,EACF;AAEA,MAAI,SAAS,IAAI;AACf,WAAO,EAAE,OAAO,KAAK;AAAA,EACvB;AAEA,QAAM,SAAS,OAAO,OAAQ,QAAQ,MAAM,IAAK,IAAK,QAAQ,IAAK,IAAI;AACvE,MAAI,CAAC,OAAO,SAAS,MAAM,GAAG;AAC5B,UAAM;AAAA,MACJ,iDAAiD,MAAM;AAAA,IACzD;AAAA,EACF;AACA,SAAO,EAAE,OAAO,QAAQ,MAAM,GAAG;AACnC;AAEA,SAAS,gBAAgB,QAAiC;AACxD,SAAO,IAAI;AAAA,IACT;AAAA,IACA,uBAAuB,MAAM;AAAA,EAC/B;AACF;","names":[]}
@@ -11,7 +11,7 @@ var RecipeValidationError = class extends RecipeBookError {
11
11
  * @param subject - What was validated, shown as given at the start of the
12
12
  * message, e.g. `'"Buttermilk pancakes"'`. Recipe Book's own errors quote
13
13
  * the recipe's name or the input text like a JSON string, cut with "…"
14
- * when longer than 60 characters.
14
+ * when longer than 60 UTF-16 code units.
15
15
  */
16
16
  constructor(code, issues, subject = code === "invalid_recipe" ? "Recipe" : code === "invalid_quantity" ? "Quantity" : "Ingredient") {
17
17
  super(code, summarize(subject, issues));
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/validation.ts"],"sourcesContent":["import {\n type ParseResult,\n quote,\n RecipeBookError,\n type RecipeIssue,\n type RecipeIssueCode,\n truncate,\n type ValidationErrorCode,\n} from './errors'\nimport { hasOwn, isRecord, issue, type Path, warning } from './issues'\nimport { closestMatch } from './suggest'\n\n// Only parsing and fromJsonLd use this module. Nothing React's imports from core\n// load may import it, or every page with React parts loads it once any page\n// parses. scripts/check-parser-split.js checks this.\n\n/** Thrown, or returned by the `parse*` functions, when data fails validation. */\nexport class RecipeValidationError extends RecipeBookError {\n declare readonly code: ValidationErrorCode\n /**\n * Every problem found, errors and warnings, in a stable order: fields in\n * model order (recipe fields, ingredients, steps, notes, equipment, then\n * nutrition), list items in input order, and unknown fields last within\n * their object.\n */\n declare readonly issues: readonly RecipeIssue[]\n\n /**\n * @param subject - What was validated, shown as given at the start of the\n * message, e.g. `'\"Buttermilk pancakes\"'`. Recipe Book's own errors quote\n * the recipe's name or the input text like a JSON string, cut with \"…\"\n * when longer than 60 characters.\n */\n constructor(\n code: ValidationErrorCode,\n issues: readonly RecipeIssue[],\n subject = code === 'invalid_recipe'\n ? 'Recipe'\n : code === 'invalid_quantity'\n ? 'Quantity'\n : 'Ingredient',\n ) {\n super(code, summarize(subject, issues))\n // The base class declares `name` readonly.\n Object.assign(this, { name: 'RecipeValidationError' })\n this.issues = issues\n }\n}\n\n// Messages end up in logs, so they stay bounded however large the input is.\nconst MAX_LISTED = 10\n// Keys in paths come from input, so only ASCII identifiers print bare. Others\n// print quoted, so `[\"a.b\"]` and look-alikes such as `bakeꓸtime` can't pass for nesting.\nconst IDENTIFIER = /^[A-Za-z_$][\\w$]*$/\n\nfunction summarize(subject: string, issues: readonly RecipeIssue[]): string {\n const count = issues.length\n const header = `${subject} has ${count} ${count === 1 ? 'problem' : 'problems'}:`\n const listed = issues.slice(0, MAX_LISTED)\n const paths = listed.map((issue) => formatPath(issue.path))\n const width = Math.max(0, ...paths.map((path) => path.length))\n const lines = listed.map(\n (issue, i) =>\n ` ${issue.severity === 'error' ? '✖' : '⚠'} ${paths[i]!.padEnd(width)} ${issue.message}`,\n )\n\n if (count > listed.length) {\n lines.push(\n ` …and ${count - listed.length} more. error.issues lists them all.`,\n )\n }\n\n return [header, ...lines].join('\\n')\n}\n\nfunction formatPath(path: readonly (string | number)[]): string {\n if (path.length === 0) {\n return '(root)'\n }\n\n return path.reduce<string>(\n (text, segment) =>\n typeof segment === 'number'\n ? `${text}[${segment}]`\n : IDENTIFIER.test(segment)\n ? text\n ? `${text}.${truncate(segment)}`\n : truncate(segment)\n : `${text}[${quote(segment)}]`,\n '',\n )\n}\n\n/** Records an error and returns `undefined`, so readers can `return error(...)`. */\nexport function error(\n issues: RecipeIssue[],\n code: RecipeIssueCode,\n path: Path,\n message: string,\n suggestion?: string,\n): undefined {\n issues.push(issue('error', code, path, message, suggestion))\n return undefined\n}\n\nexport function toResult<T>(\n code: RecipeValidationError['code'],\n data: T | undefined,\n issues: RecipeIssue[],\n subject?: string,\n): ParseResult<T> {\n if (\n data === undefined ||\n issues.some((issue) => issue.severity === 'error')\n ) {\n return {\n success: false,\n error: new RecipeValidationError(code, issues, subject),\n }\n }\n\n return { success: true, data, warnings: issues }\n}\n\n/**\n * A copy of an object's own enumerable fields on a null prototype, so fields\n * inherited from a prototype, including a polluted `Object.prototype`, are never read.\n */\nexport function asRecord(value: unknown): Record<string, unknown> | undefined {\n return isRecord(value)\n ? Object.assign(Object.create(null) as Record<string, unknown>, value)\n : undefined\n}\n\n/** The known keys of `T`. The type makes the compiler check that none is missing or extra. */\nexport type Fields<T> = { readonly [K in keyof T]-?: true }\n\n/**\n * Warns about each key of `record` that isn't in `fields`. Suggestions ignore\n * a trailing `Content`, so schema.org names such as `fatContent` suggest `fat`.\n */\nexport function checkFields(\n issues: RecipeIssue[],\n record: Record<string, unknown>,\n fields: Readonly<Record<string, unknown>>,\n path: Path,\n): void {\n for (const key of Object.keys(record)) {\n if (hasOwn(fields, key)) {\n continue\n }\n\n const suggestion = closestMatch(\n key.replace(/Content$/, ''),\n Object.keys(fields),\n )\n\n warning(\n issues,\n 'unknown_field',\n [...path, key],\n suggestion\n ? `Unknown field. Did you mean \"${suggestion}\"?`\n : 'Unknown field. It is ignored.',\n suggestion,\n )\n }\n}\n"],"mappings":";AAAA;AAAA,EAEE;AAAA,EACA;AAAA,EAGA;AAAA,OAEK;AACP,SAAS,QAAQ,UAAU,OAAkB,eAAe;AAC5D,SAAS,oBAAoB;AAOtB,IAAM,wBAAN,cAAoC,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBzD,YACE,MACA,QACA,UAAU,SAAS,mBACf,WACA,SAAS,qBACP,aACA,cACN;AACA,UAAM,MAAM,UAAU,SAAS,MAAM,CAAC;AAEtC,WAAO,OAAO,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,SAAK,SAAS;AAAA,EAChB;AACF;AAGA,IAAM,aAAa;AAGnB,IAAM,aAAa;AAEnB,SAAS,UAAU,SAAiB,QAAwC;AAC1E,QAAM,QAAQ,OAAO;AACrB,QAAM,SAAS,GAAG,OAAO,QAAQ,KAAK,IAAI,UAAU,IAAI,YAAY,UAAU;AAC9E,QAAM,SAAS,OAAO,MAAM,GAAG,UAAU;AACzC,QAAM,QAAQ,OAAO,IAAI,CAACA,WAAU,WAAWA,OAAM,IAAI,CAAC;AAC1D,QAAM,QAAQ,KAAK,IAAI,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,KAAK,MAAM,CAAC;AAC7D,QAAM,QAAQ,OAAO;AAAA,IACnB,CAACA,QAAO,MACN,KAAKA,OAAM,aAAa,UAAU,WAAM,QAAG,IAAI,MAAM,CAAC,EAAG,OAAO,KAAK,CAAC,KAAKA,OAAM,OAAO;AAAA,EAC5F;AAEA,MAAI,QAAQ,OAAO,QAAQ;AACzB,UAAM;AAAA,MACJ,eAAU,QAAQ,OAAO,MAAM;AAAA,IACjC;AAAA,EACF;AAEA,SAAO,CAAC,QAAQ,GAAG,KAAK,EAAE,KAAK,IAAI;AACrC;AAEA,SAAS,WAAW,MAA4C;AAC9D,MAAI,KAAK,WAAW,GAAG;AACrB,WAAO;AAAA,EACT;AAEA,SAAO,KAAK;AAAA,IACV,CAAC,MAAM,YACL,OAAO,YAAY,WACf,GAAG,IAAI,IAAI,OAAO,MAClB,WAAW,KAAK,OAAO,IACrB,OACE,GAAG,IAAI,IAAI,SAAS,OAAO,CAAC,KAC5B,SAAS,OAAO,IAClB,GAAG,IAAI,IAAI,MAAM,OAAO,CAAC;AAAA,IACjC;AAAA,EACF;AACF;AAGO,SAAS,MACd,QACA,MACA,MACA,SACA,YACW;AACX,SAAO,KAAK,MAAM,SAAS,MAAM,MAAM,SAAS,UAAU,CAAC;AAC3D,SAAO;AACT;AAEO,SAAS,SACd,MACA,MACA,QACA,SACgB;AAChB,MACE,SAAS,UACT,OAAO,KAAK,CAACA,WAAUA,OAAM,aAAa,OAAO,GACjD;AACA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,IAAI,sBAAsB,MAAM,QAAQ,OAAO;AAAA,IACxD;AAAA,EACF;AAEA,SAAO,EAAE,SAAS,MAAM,MAAM,UAAU,OAAO;AACjD;AAMO,SAAS,SAAS,OAAqD;AAC5E,SAAO,SAAS,KAAK,IACjB,OAAO,OAAO,uBAAO,OAAO,IAAI,GAA8B,KAAK,IACnE;AACN;AASO,SAAS,YACd,QACA,QACA,QACA,MACM;AACN,aAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AACrC,QAAI,OAAO,QAAQ,GAAG,GAAG;AACvB;AAAA,IACF;AAEA,UAAM,aAAa;AAAA,MACjB,IAAI,QAAQ,YAAY,EAAE;AAAA,MAC1B,OAAO,KAAK,MAAM;AAAA,IACpB;AAEA;AAAA,MACE;AAAA,MACA;AAAA,MACA,CAAC,GAAG,MAAM,GAAG;AAAA,MACb,aACI,gCAAgC,UAAU,OAC1C;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AACF;","names":["issue"]}
1
+ {"version":3,"sources":["../src/validation.ts"],"sourcesContent":["import {\n type ParseResult,\n quote,\n RecipeBookError,\n type RecipeIssue,\n type RecipeIssueCode,\n truncate,\n type ValidationErrorCode,\n} from './errors'\nimport { hasOwn, isRecord, issue, type Path, warning } from './issues'\nimport { closestMatch } from './suggest'\n\n// Only parsing and fromJsonLd use this module. Nothing React's imports from core\n// load may import it, or every page with React parts loads it once any page\n// parses. scripts/check-parser-split.js checks this.\n\n/** Thrown, or returned by the `parse*` functions, when data fails validation. */\nexport class RecipeValidationError extends RecipeBookError {\n declare readonly code: ValidationErrorCode\n /**\n * Every problem found, errors and warnings, in a stable order: fields in\n * model order (recipe fields, ingredients, steps, notes, equipment, then\n * nutrition), list items in input order, and unknown fields last within\n * their object.\n */\n declare readonly issues: readonly RecipeIssue[]\n\n /**\n * @param subject - What was validated, shown as given at the start of the\n * message, e.g. `'\"Buttermilk pancakes\"'`. Recipe Book's own errors quote\n * the recipe's name or the input text like a JSON string, cut with \"…\"\n * when longer than 60 UTF-16 code units.\n */\n constructor(\n code: ValidationErrorCode,\n issues: readonly RecipeIssue[],\n subject = code === 'invalid_recipe'\n ? 'Recipe'\n : code === 'invalid_quantity'\n ? 'Quantity'\n : 'Ingredient',\n ) {\n super(code, summarize(subject, issues))\n // The base class declares `name` readonly.\n Object.assign(this, { name: 'RecipeValidationError' })\n this.issues = issues\n }\n}\n\n// Messages end up in logs, so they stay bounded however large the input is.\nconst MAX_LISTED = 10\n// Keys in paths come from input, so only ASCII identifiers print bare. Others\n// print quoted, so `[\"a.b\"]` and look-alikes such as `bakeꓸtime` can't pass for nesting.\nconst IDENTIFIER = /^[A-Za-z_$][\\w$]*$/\n\nfunction summarize(subject: string, issues: readonly RecipeIssue[]): string {\n const count = issues.length\n const header = `${subject} has ${count} ${count === 1 ? 'problem' : 'problems'}:`\n const listed = issues.slice(0, MAX_LISTED)\n const paths = listed.map((issue) => formatPath(issue.path))\n const width = Math.max(0, ...paths.map((path) => path.length))\n const lines = listed.map(\n (issue, i) =>\n ` ${issue.severity === 'error' ? '✖' : '⚠'} ${paths[i]!.padEnd(width)} ${issue.message}`,\n )\n\n if (count > listed.length) {\n lines.push(\n ` …and ${count - listed.length} more. error.issues lists them all.`,\n )\n }\n\n return [header, ...lines].join('\\n')\n}\n\nfunction formatPath(path: readonly (string | number)[]): string {\n if (path.length === 0) {\n return '(root)'\n }\n\n return path.reduce<string>(\n (text, segment) =>\n typeof segment === 'number'\n ? `${text}[${segment}]`\n : IDENTIFIER.test(segment)\n ? text\n ? `${text}.${truncate(segment)}`\n : truncate(segment)\n : `${text}[${quote(segment)}]`,\n '',\n )\n}\n\n/** Records an error and returns `undefined`, so readers can `return error(...)`. */\nexport function error(\n issues: RecipeIssue[],\n code: RecipeIssueCode,\n path: Path,\n message: string,\n suggestion?: string,\n): undefined {\n issues.push(issue('error', code, path, message, suggestion))\n return undefined\n}\n\nexport function toResult<T>(\n code: RecipeValidationError['code'],\n data: T | undefined,\n issues: RecipeIssue[],\n subject?: string,\n): ParseResult<T> {\n if (\n data === undefined ||\n issues.some((issue) => issue.severity === 'error')\n ) {\n return {\n success: false,\n error: new RecipeValidationError(code, issues, subject),\n }\n }\n\n return { success: true, data, warnings: issues }\n}\n\n/**\n * A copy of an object's own enumerable fields on a null prototype, so fields\n * inherited from a prototype, including a polluted `Object.prototype`, are never read.\n */\nexport function asRecord(value: unknown): Record<string, unknown> | undefined {\n return isRecord(value)\n ? Object.assign(Object.create(null) as Record<string, unknown>, value)\n : undefined\n}\n\n/** The known keys of `T`. The type makes the compiler check that none is missing or extra. */\nexport type Fields<T> = { readonly [K in keyof T]-?: true }\n\n/**\n * Warns about each key of `record` that isn't in `fields`. Suggestions ignore\n * a trailing `Content`, so schema.org names such as `fatContent` suggest `fat`.\n */\nexport function checkFields(\n issues: RecipeIssue[],\n record: Record<string, unknown>,\n fields: Readonly<Record<string, unknown>>,\n path: Path,\n): void {\n for (const key of Object.keys(record)) {\n if (hasOwn(fields, key)) {\n continue\n }\n\n const suggestion = closestMatch(\n key.replace(/Content$/, ''),\n Object.keys(fields),\n )\n\n warning(\n issues,\n 'unknown_field',\n [...path, key],\n suggestion\n ? `Unknown field. Did you mean \"${suggestion}\"?`\n : 'Unknown field. It is ignored.',\n suggestion,\n )\n }\n}\n"],"mappings":";AAAA;AAAA,EAEE;AAAA,EACA;AAAA,EAGA;AAAA,OAEK;AACP,SAAS,QAAQ,UAAU,OAAkB,eAAe;AAC5D,SAAS,oBAAoB;AAOtB,IAAM,wBAAN,cAAoC,gBAAgB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBzD,YACE,MACA,QACA,UAAU,SAAS,mBACf,WACA,SAAS,qBACP,aACA,cACN;AACA,UAAM,MAAM,UAAU,SAAS,MAAM,CAAC;AAEtC,WAAO,OAAO,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,SAAK,SAAS;AAAA,EAChB;AACF;AAGA,IAAM,aAAa;AAGnB,IAAM,aAAa;AAEnB,SAAS,UAAU,SAAiB,QAAwC;AAC1E,QAAM,QAAQ,OAAO;AACrB,QAAM,SAAS,GAAG,OAAO,QAAQ,KAAK,IAAI,UAAU,IAAI,YAAY,UAAU;AAC9E,QAAM,SAAS,OAAO,MAAM,GAAG,UAAU;AACzC,QAAM,QAAQ,OAAO,IAAI,CAACA,WAAU,WAAWA,OAAM,IAAI,CAAC;AAC1D,QAAM,QAAQ,KAAK,IAAI,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,KAAK,MAAM,CAAC;AAC7D,QAAM,QAAQ,OAAO;AAAA,IACnB,CAACA,QAAO,MACN,KAAKA,OAAM,aAAa,UAAU,WAAM,QAAG,IAAI,MAAM,CAAC,EAAG,OAAO,KAAK,CAAC,KAAKA,OAAM,OAAO;AAAA,EAC5F;AAEA,MAAI,QAAQ,OAAO,QAAQ;AACzB,UAAM;AAAA,MACJ,eAAU,QAAQ,OAAO,MAAM;AAAA,IACjC;AAAA,EACF;AAEA,SAAO,CAAC,QAAQ,GAAG,KAAK,EAAE,KAAK,IAAI;AACrC;AAEA,SAAS,WAAW,MAA4C;AAC9D,MAAI,KAAK,WAAW,GAAG;AACrB,WAAO;AAAA,EACT;AAEA,SAAO,KAAK;AAAA,IACV,CAAC,MAAM,YACL,OAAO,YAAY,WACf,GAAG,IAAI,IAAI,OAAO,MAClB,WAAW,KAAK,OAAO,IACrB,OACE,GAAG,IAAI,IAAI,SAAS,OAAO,CAAC,KAC5B,SAAS,OAAO,IAClB,GAAG,IAAI,IAAI,MAAM,OAAO,CAAC;AAAA,IACjC;AAAA,EACF;AACF;AAGO,SAAS,MACd,QACA,MACA,MACA,SACA,YACW;AACX,SAAO,KAAK,MAAM,SAAS,MAAM,MAAM,SAAS,UAAU,CAAC;AAC3D,SAAO;AACT;AAEO,SAAS,SACd,MACA,MACA,QACA,SACgB;AAChB,MACE,SAAS,UACT,OAAO,KAAK,CAACA,WAAUA,OAAM,aAAa,OAAO,GACjD;AACA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,IAAI,sBAAsB,MAAM,QAAQ,OAAO;AAAA,IACxD;AAAA,EACF;AAEA,SAAO,EAAE,SAAS,MAAM,MAAM,UAAU,OAAO;AACjD;AAMO,SAAS,SAAS,OAAqD;AAC5E,SAAO,SAAS,KAAK,IACjB,OAAO,OAAO,uBAAO,OAAO,IAAI,GAA8B,KAAK,IACnE;AACN;AASO,SAAS,YACd,QACA,QACA,QACA,MACM;AACN,aAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AACrC,QAAI,OAAO,QAAQ,GAAG,GAAG;AACvB;AAAA,IACF;AAEA,UAAM,aAAa;AAAA,MACjB,IAAI,QAAQ,YAAY,EAAE;AAAA,MAC1B,OAAO,KAAK,MAAM;AAAA,IACpB;AAEA;AAAA,MACE;AAAA,MACA;AAAA,MACA,CAAC,GAAG,MAAM,GAAG;AAAA,MACb,aACI,gCAAgC,UAAU,OAC1C;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AACF;","names":["issue"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recipe-book/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Framework-agnostic recipe parsing, scaling, unit conversion and formatting.",
5
5
  "homepage": "https://recipe-book.siraj.page",
6
6
  "license": "MIT",