@recipe-book/core 0.0.3 → 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.
- package/CHANGELOG.md +116 -0
- package/LICENSE +21 -0
- package/README.md +50 -7
- package/dist/chunk-D5CV3UF7.cjs +2754 -0
- package/dist/chunk-D5CV3UF7.cjs.map +1 -0
- package/dist/errors.js +76 -0
- package/dist/errors.js.map +1 -0
- package/dist/format/duration.js +36 -0
- package/dist/format/duration.js.map +1 -0
- package/dist/format/ingredient.js +49 -0
- package/dist/format/ingredient.js.map +1 -0
- package/dist/format/number.js +120 -0
- package/dist/format/number.js.map +1 -0
- package/dist/format/options.js +31 -0
- package/dist/format/options.js.map +1 -0
- package/dist/format/quantity.js +144 -0
- package/dist/format/quantity.js.map +1 -0
- package/dist/format/servings.js +68 -0
- package/dist/format/servings.js.map +1 -0
- package/dist/format/temperature.js +35 -0
- package/dist/format/temperature.js.map +1 -0
- package/dist/index.cjs +476 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +994 -0
- package/dist/index.d.ts +994 -5
- package/dist/index.js +48 -1
- package/dist/index.js.map +1 -0
- package/dist/ingredient/parse.js +412 -0
- package/dist/ingredient/parse.js.map +1 -0
- package/dist/issues.js +26 -0
- package/dist/issues.js.map +1 -0
- package/dist/quantity/amount.js +78 -0
- package/dist/quantity/amount.js.map +1 -0
- package/dist/quantity/parse.js +255 -0
- package/dist/quantity/parse.js.map +1 -0
- package/dist/quantity/scale.js +23 -0
- package/dist/quantity/scale.js.map +1 -0
- package/dist/recipe/duration.js +28 -0
- package/dist/recipe/duration.js.map +1 -0
- package/dist/recipe/ids.js +68 -0
- package/dist/recipe/ids.js.map +1 -0
- package/dist/recipe/infer.js +24 -0
- package/dist/recipe/infer.js.map +1 -0
- package/dist/recipe/nutrient-units.js +18 -0
- package/dist/recipe/nutrient-units.js.map +1 -0
- package/dist/recipe/nutrition.js +26 -0
- package/dist/recipe/nutrition.js.map +1 -0
- package/dist/recipe/parse.js +854 -0
- package/dist/recipe/parse.js.map +1 -0
- package/dist/recipe/schema.js +32 -0
- package/dist/recipe/schema.js.map +1 -0
- package/dist/recipe/url.js +9 -0
- package/dist/recipe/url.js.map +1 -0
- package/dist/resolve.js +120 -0
- package/dist/resolve.js.map +1 -0
- package/dist/schema-org/find.js +158 -0
- package/dist/schema-org/find.js.map +1 -0
- package/dist/schema-org/from-json-ld.js +647 -0
- package/dist/schema-org/from-json-ld.js.map +1 -0
- package/dist/schema-org/nutrients.js +19 -0
- package/dist/schema-org/nutrients.js.map +1 -0
- package/dist/schema-org/text.js +125 -0
- package/dist/schema-org/text.js.map +1 -0
- package/dist/schema-org/to-json-ld.js +151 -0
- package/dist/schema-org/to-json-ld.js.map +1 -0
- package/dist/schema-org.cjs +1062 -0
- package/dist/schema-org.cjs.map +1 -0
- package/dist/schema-org.d.cts +223 -0
- package/dist/schema-org.d.ts +223 -0
- package/dist/schema-org.js +10 -0
- package/dist/schema-org.js.map +1 -0
- package/dist/steps/segment.js +45 -0
- package/dist/steps/segment.js.map +1 -0
- package/dist/steps/temperature.js +174 -0
- package/dist/steps/temperature.js.map +1 -0
- package/dist/suggest.js +50 -0
- package/dist/suggest.js.map +1 -0
- package/dist/types-BknM8KOd.d.cts +628 -0
- package/dist/types-BknM8KOd.d.ts +628 -0
- package/dist/units/convert.js +79 -0
- package/dist/units/convert.js.map +1 -0
- package/dist/units/lookup.js +86 -0
- package/dist/units/lookup.js.map +1 -0
- package/dist/units/registry.js +183 -0
- package/dist/units/registry.js.map +1 -0
- package/dist/units/system.js +91 -0
- package/dist/units/system.js.map +1 -0
- package/dist/units/temperature.js +40 -0
- package/dist/units/temperature.js.map +1 -0
- package/dist/validation.js +90 -0
- package/dist/validation.js.map +1 -0
- package/package.json +53 -8
- package/schema-org/package.json +5 -0
- package/dist/chunk-22RBQQWH.mjs +0 -1
- package/dist/chunk-3CSP4UZG.mjs +0 -1
- package/dist/chunk-3FFMHRB7.mjs +0 -26
- package/dist/chunk-4EB3HG6B.mjs +0 -50
- package/dist/chunk-4EHNJLGI.mjs +0 -50
- package/dist/chunk-4IA5EEKI.mjs +0 -81
- package/dist/chunk-4TATADPT.mjs +0 -51
- package/dist/chunk-6GI5KLI4.mjs +0 -22
- package/dist/chunk-6QJZOZ6W.mjs +0 -49
- package/dist/chunk-6YYQ4RYZ.mjs +0 -26
- package/dist/chunk-7BBUDI2L.mjs +0 -0
- package/dist/chunk-7KEMNCV7.mjs +0 -26
- package/dist/chunk-7MMJZZ7Z.mjs +0 -75
- package/dist/chunk-BES4V7DM.mjs +0 -26
- package/dist/chunk-BRDXRIAH.mjs +0 -50
- package/dist/chunk-C45UD45A.mjs +0 -1
- package/dist/chunk-CBLJXA6P.mjs +0 -1
- package/dist/chunk-CFR2ZBPB.mjs +0 -77
- package/dist/chunk-DYMNVFL2.mjs +0 -78
- package/dist/chunk-F6AQKXJU.mjs +0 -51
- package/dist/chunk-FW4M2R43.mjs +0 -51
- package/dist/chunk-GCCEDUST.mjs +0 -75
- package/dist/chunk-GE7KBYZR.mjs +0 -26
- package/dist/chunk-GN7ZDGYC.mjs +0 -51
- package/dist/chunk-GOO2SCQX.mjs +0 -75
- package/dist/chunk-HAVKBQLH.mjs +0 -51
- package/dist/chunk-HCBIPANU.mjs +0 -50
- package/dist/chunk-IEBAGBS7.mjs +0 -51
- package/dist/chunk-IQ6WN2V4.mjs +0 -75
- package/dist/chunk-JB4Z2FR7.mjs +0 -77
- package/dist/chunk-JMTQOTBM.mjs +0 -75
- package/dist/chunk-JRO6JUES.mjs +0 -51
- package/dist/chunk-KH3SRXET.mjs +0 -52
- package/dist/chunk-KNZMCAQZ.mjs +0 -51
- package/dist/chunk-KXFUCO3R.mjs +0 -1
- package/dist/chunk-LEBGCVCJ.mjs +0 -75
- package/dist/chunk-MAHWFCIL.mjs +0 -51
- package/dist/chunk-MPH76SLC.mjs +0 -51
- package/dist/chunk-MQGMFBJQ.mjs +0 -26
- package/dist/chunk-NMIVG7T4.mjs +0 -6
- package/dist/chunk-Q3TT3AZ4.mjs +0 -26
- package/dist/chunk-QY5HJQIL.mjs +0 -51
- package/dist/chunk-RCPLSNKM.mjs +0 -1
- package/dist/chunk-SR2KFBT3.mjs +0 -1
- package/dist/chunk-ULDLBAEG.mjs +0 -1
- package/dist/chunk-UQ3EUYFR.mjs +0 -1
- package/dist/chunk-V26OCCTL.mjs +0 -82
- package/dist/chunk-WBQAMGXK.mjs +0 -0
- package/dist/chunk-WSW6FMUL.mjs +0 -51
- package/dist/chunk-WU55SBIV.mjs +0 -75
- package/dist/chunk-XINK45P3.mjs +0 -51
- package/dist/chunk-XNOHIWI3.mjs +0 -1
- package/dist/chunk-XV4QN4LA.mjs +0 -6
- package/dist/chunk-YHBXEVUX.mjs +0 -51
- package/dist/chunk-YSZSCEI7.mjs +0 -75
- package/dist/chunk-YY3SUQ7P.mjs +0 -16
- package/dist/chunk-YZB3IG47.mjs +0 -80
- package/dist/chunk-ZPZT4LMQ.mjs +0 -25
- package/dist/const/conversion.d.mts +0 -7
- package/dist/const/conversion.d.ts +0 -7
- package/dist/const/conversion.js +0 -1
- package/dist/const/conversion.mjs +0 -1
- package/dist/const/unit.d.mts +0 -119
- package/dist/const/unit.d.ts +0 -119
- package/dist/const/unit.js +0 -1
- package/dist/const/unit.mjs +0 -1
- package/dist/index.d.mts +0 -5
- package/dist/index.mjs +0 -1
- package/dist/lib/assert.d.mts +0 -18
- package/dist/lib/assert.d.ts +0 -18
- package/dist/lib/assert.js +0 -1
- package/dist/lib/assert.mjs +0 -1
- package/dist/lib/ingredient.d.mts +0 -9
- package/dist/lib/ingredient.d.ts +0 -9
- package/dist/lib/ingredient.js +0 -1
- package/dist/lib/ingredient.mjs +0 -1
- package/dist/lib/recipe.d.mts +0 -12
- package/dist/lib/recipe.d.ts +0 -12
- package/dist/lib/recipe.js +0 -1
- package/dist/lib/recipe.mjs +0 -1
- package/dist/lib/runtime.d.mts +0 -3
- package/dist/lib/runtime.d.ts +0 -3
- package/dist/lib/runtime.js +0 -1
- package/dist/lib/runtime.mjs +0 -1
- package/dist/lib/unit.d.mts +0 -19
- package/dist/lib/unit.d.ts +0 -19
- package/dist/lib/unit.js +0 -1
- package/dist/lib/unit.mjs +0 -1
- package/dist/types.d.mts +0 -36
- package/dist/types.d.ts +0 -36
- package/dist/types.js +0 -1
- package/dist/types.mjs +0 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { ParseResult, Recipe, System, RecipeIssue } from './index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finds the schema.org Recipe nodes in a page's
|
|
5
|
+
* `<script type="application/ld+json">` tags, without a DOM. Pass one to
|
|
6
|
+
* `fromJsonLd`.
|
|
7
|
+
*
|
|
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.
|
|
29
|
+
*
|
|
30
|
+
* In each Recipe node returned, an image that is an `@id` reference to a node
|
|
31
|
+
* at the top level of the JSON-LD, or reached from there through arrays and
|
|
32
|
+
* `@graph`, holds that node instead of the reference, an object that holds only
|
|
33
|
+
* that `@id`. That applies to the recipe's `image` and each step's `image` in
|
|
34
|
+
* `recipeInstructions`, and lets `fromJsonLd` read them. A reference to a node
|
|
35
|
+
* in any other field, such as an `Organization`'s `logo` or an `ImageObject` in
|
|
36
|
+
* a `WebPage`'s `mainEntity`, isn't followed. Other references, such as
|
|
37
|
+
* `author` and `mainEntityOfPage`, and references inside the node an image
|
|
38
|
+
* holds, stay as the page wrote them.
|
|
39
|
+
*
|
|
40
|
+
* @param html - The page's HTML, such as `await response.text()`.
|
|
41
|
+
* @returns The Recipe nodes it finds, in document order, or `[]` when there
|
|
42
|
+
* are none.
|
|
43
|
+
* @throws {RecipeBookError} `invalid_argument` when `html` isn't a string.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const [node] = findRecipeJsonLd(await response.text())
|
|
47
|
+
* const result = fromJsonLd(node)
|
|
48
|
+
*/
|
|
49
|
+
declare function findRecipeJsonLd(html: string): Record<string, unknown>[];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A schema.org Recipe as `toJsonLd` writes it: plain JSON when `extra` is, so
|
|
53
|
+
* a Server Component can render it into a `<script type="application/ld+json">`
|
|
54
|
+
* tag. Fields from the `extra` option sit alongside these with the values you
|
|
55
|
+
* passed, including a field core leaves out, such as an `image` URL for a
|
|
56
|
+
* recipe that has none.
|
|
57
|
+
*/
|
|
58
|
+
type SchemaOrgRecipe = {
|
|
59
|
+
'@context': 'https://schema.org';
|
|
60
|
+
'@type': 'Recipe';
|
|
61
|
+
name: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
/** An http or https URL, or a relative one. */
|
|
64
|
+
image?: string;
|
|
65
|
+
/** `["4", "4 servings"]` for servings, then the yield, as in `"24 cookies"`. */
|
|
66
|
+
recipeYield?: string[];
|
|
67
|
+
/** ISO 8601, such as `"PT1H30M"`. */
|
|
68
|
+
prepTime?: string;
|
|
69
|
+
cookTime?: string;
|
|
70
|
+
totalTime?: string;
|
|
71
|
+
/** Ingredient lines, such as `"1 ½ cups (190 g) flour, sifted"`. */
|
|
72
|
+
recipeIngredient?: string[];
|
|
73
|
+
/** Steps, with each titled section that has steps as a `HowToSection`. */
|
|
74
|
+
recipeInstructions?: (SchemaOrgHowToStep | SchemaOrgHowToSection)[];
|
|
75
|
+
/** Equipment names. */
|
|
76
|
+
tool?: string[];
|
|
77
|
+
nutrition?: SchemaOrgNutritionInformation;
|
|
78
|
+
[field: string]: unknown;
|
|
79
|
+
};
|
|
80
|
+
/** A step in {@link SchemaOrgRecipe}. */
|
|
81
|
+
type SchemaOrgHowToStep = {
|
|
82
|
+
'@type': 'HowToStep';
|
|
83
|
+
name?: string;
|
|
84
|
+
text: string;
|
|
85
|
+
image?: string;
|
|
86
|
+
/** The `url` option with the step's id, percent-encoded, as its fragment, as in `"https://example.com/pancakes#mix"`. */
|
|
87
|
+
url?: string;
|
|
88
|
+
};
|
|
89
|
+
/** A titled section of steps in {@link SchemaOrgRecipe}. */
|
|
90
|
+
type SchemaOrgHowToSection = {
|
|
91
|
+
'@type': 'HowToSection';
|
|
92
|
+
name: string;
|
|
93
|
+
itemListElement: SchemaOrgHowToStep[];
|
|
94
|
+
};
|
|
95
|
+
/** Nutrition for one serving in {@link SchemaOrgRecipe}, as text with units, such as `"250 kcal"` or `"12 g"`. */
|
|
96
|
+
type SchemaOrgNutritionInformation = {
|
|
97
|
+
'@type': 'NutritionInformation';
|
|
98
|
+
servingSize?: string;
|
|
99
|
+
calories?: string;
|
|
100
|
+
fatContent?: string;
|
|
101
|
+
saturatedFatContent?: string;
|
|
102
|
+
transFatContent?: string;
|
|
103
|
+
unsaturatedFatContent?: string;
|
|
104
|
+
carbohydrateContent?: string;
|
|
105
|
+
sugarContent?: string;
|
|
106
|
+
fiberContent?: string;
|
|
107
|
+
proteinContent?: string;
|
|
108
|
+
sodiumContent?: string;
|
|
109
|
+
cholesterolContent?: string;
|
|
110
|
+
};
|
|
111
|
+
/** The {@link SchemaOrgRecipe} fields `toJsonLd` writes from a recipe. */
|
|
112
|
+
type Modelled = 'name' | 'description' | 'image' | 'recipeYield' | 'prepTime' | 'cookTime' | 'totalTime' | 'recipeIngredient' | 'recipeInstructions' | 'tool' | 'nutrition';
|
|
113
|
+
/** Options for `toJsonLd`. */
|
|
114
|
+
type ToJsonLdOptions = {
|
|
115
|
+
/** Writes the recipe at this many servings, as `resolveRecipe` scales it. Defaults to the recipe's own. */
|
|
116
|
+
servings?: number | undefined;
|
|
117
|
+
/** Writes quantities in this measurement system. Defaults to `recipe.system`, then `'metric'`. */
|
|
118
|
+
system?: System | undefined;
|
|
119
|
+
/** The recipe page's URL. Each step gets it as `url`, with the step's id, percent-encoded, as the fragment. */
|
|
120
|
+
url?: string | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* schema.org fields the recipe doesn't model, such as `author`,
|
|
123
|
+
* `datePublished`, or `keywords`, written as you pass them. A field core
|
|
124
|
+
* writes from the recipe wins; `@context` and `@type` are always core's. A
|
|
125
|
+
* field that is `undefined` is left out, so `{ image: maybeImage }` adds an
|
|
126
|
+
* image only when there is one. A field core writes from a recipe takes the
|
|
127
|
+
* type {@link SchemaOrgRecipe} gives it here too, so an `image` is a URL.
|
|
128
|
+
* Pass the `extra` that `fromJsonLd` returned to write them back.
|
|
129
|
+
*/
|
|
130
|
+
extra?: ({
|
|
131
|
+
[K in Modelled]?: SchemaOrgRecipe[K] | undefined;
|
|
132
|
+
} & Record<string, unknown>) | undefined;
|
|
133
|
+
};
|
|
134
|
+
/** What `toJsonLd` returns. */
|
|
135
|
+
type ToJsonLdResult = {
|
|
136
|
+
data: SchemaOrgRecipe;
|
|
137
|
+
warnings: RecipeIssue[];
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* What `fromJsonLd` returns: a `ParseResult`, with `extra` on success and
|
|
141
|
+
* failure alike. `extra` holds the node's fields the recipe doesn't model.
|
|
142
|
+
*/
|
|
143
|
+
type FromJsonLdResult = ParseResult<Recipe> & {
|
|
144
|
+
extra: Record<string, unknown>;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reads a schema.org Recipe from JSON-LD, as recipe sites publish it for
|
|
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.
|
|
154
|
+
*
|
|
155
|
+
* Every string has its tags removed, then its HTML entities decoded, so an
|
|
156
|
+
* encoded tag such as `<b>` stays as text. Ingredient lines are read
|
|
157
|
+
* with `parseIngredientLine`. Issue paths point into the JSON-LD, such as
|
|
158
|
+
* `recipeIngredient[3]`.
|
|
159
|
+
*
|
|
160
|
+
* Reading is forgiving. A value the recipe can't hold, such as a `data:`
|
|
161
|
+
* image or nutrition of "N/A", is left out with a warning that keeps its usual
|
|
162
|
+
* code and ends "It is ignored." A second Recipe node, a second number of
|
|
163
|
+
* servings, or a second yield is an `ignored_value` warning. The result fails
|
|
164
|
+
* only when there is no Recipe node, the recipe's name is missing or isn't
|
|
165
|
+
* text, or a problem remains that leaving a value out can't fix.
|
|
166
|
+
*
|
|
167
|
+
* @returns The recipe or the error, and `extra`: the node's fields the recipe
|
|
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.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* const result = fromJsonLd(node)
|
|
175
|
+
* if (!result.success) {
|
|
176
|
+
* console.error(result.error.message)
|
|
177
|
+
* // Recipe has 1 problem:
|
|
178
|
+
* // ✖ name Add a name for the recipe.
|
|
179
|
+
* // See https://recipe-book.siraj.page/errors/invalid_recipe
|
|
180
|
+
* return
|
|
181
|
+
* }
|
|
182
|
+
* result.data.ingredients[0].items[0] // { id: 'flour', name: 'flour', quantity: { amount: 2, unit: 'cup' }, … }
|
|
183
|
+
* result.extra.author // { '@type': 'Person', name: 'Ada' }
|
|
184
|
+
*/
|
|
185
|
+
declare function fromJsonLd(input: unknown): FromJsonLdResult;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Writes a recipe as schema.org Recipe JSON-LD, for search engines' recipe
|
|
189
|
+
* results. Writes it at its own servings and in `recipe.system`, else metric,
|
|
190
|
+
* unless the options say otherwise; invalid options fall back to these, as in
|
|
191
|
+
* `resolveRecipe`. Never throws for an unchanged recipe from `parseRecipe` or
|
|
192
|
+
* `fromJsonLd`.
|
|
193
|
+
*
|
|
194
|
+
* `data` is plain JSON when `extra` is. Write it into a
|
|
195
|
+
* `<script type="application/ld+json">` tag with `<` escaped as `\u003c`, so
|
|
196
|
+
* recipe text can't end the tag. An `image` passed in `extra` is a URL; for an
|
|
197
|
+
* `ImageObject` or several images, spread `data` into a new object with your
|
|
198
|
+
* own `image`.
|
|
199
|
+
*
|
|
200
|
+
* Ingredient lines read back with `parseIngredientLine`: a quantity's
|
|
201
|
+
* alternatives follow it in parentheses, as in `"1 ½ cups (190 g) flour,
|
|
202
|
+
* sifted"` and `"2 eggs (100 g)"`, and an optional ingredient ends
|
|
203
|
+
* `" (optional)"`. schema.org has no place for ingredient section titles, the
|
|
204
|
+
* recipe's notes, custom times, an ingredient's `url`, or equipment beyond its
|
|
205
|
+
* name, so they aren't written. Reading `data` back with `fromJsonLd` gives a
|
|
206
|
+
* similar recipe, not the same one.
|
|
207
|
+
*
|
|
208
|
+
* `warnings` names the fields Google requires for recipe results that `data`
|
|
209
|
+
* lacks, with paths that use the schema.org names: `image`, and `recipeYield`
|
|
210
|
+
* when there is nutrition. A time longer than `Number.MAX_SAFE_INTEGER`
|
|
211
|
+
* minutes is left out, as `fromJsonLd` leaves it out, and so is a `NaN` time,
|
|
212
|
+
* each with an `invalid_duration` warning at its path in the recipe, such as
|
|
213
|
+
* `['times', 'cook']`.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* const { data } = toJsonLd(recipe, { url: 'https://example.com/pancakes' })
|
|
217
|
+
* data.recipeYield // ['4', '4 servings']
|
|
218
|
+
* data.recipeIngredient // ['1 ½ cups (190 g) flour, sifted', '2 eggs', 'salt, to taste']
|
|
219
|
+
*/
|
|
220
|
+
declare function toJsonLd(recipe: Recipe, options?: ToJsonLdOptions): ToJsonLdResult;
|
|
221
|
+
|
|
222
|
+
export { findRecipeJsonLd, fromJsonLd, toJsonLd };
|
|
223
|
+
export type { FromJsonLdResult, SchemaOrgHowToSection, SchemaOrgHowToStep, SchemaOrgNutritionInformation, SchemaOrgRecipe, ToJsonLdOptions, ToJsonLdResult };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/schema-org/index.ts
|
|
2
|
+
import { findRecipeJsonLd } from "./schema-org/find.js";
|
|
3
|
+
import { fromJsonLd } from "./schema-org/from-json-ld.js";
|
|
4
|
+
import { toJsonLd } from "./schema-org/to-json-ld.js";
|
|
5
|
+
export {
|
|
6
|
+
findRecipeJsonLd,
|
|
7
|
+
fromJsonLd,
|
|
8
|
+
toJsonLd
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=schema-org.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schema-org/index.ts"],"sourcesContent":["export { findRecipeJsonLd } from './find'\nexport { fromJsonLd } from './from-json-ld'\nexport { toJsonLd } from './to-json-ld'\nexport type {\n FromJsonLdResult,\n SchemaOrgHowToSection,\n SchemaOrgHowToStep,\n SchemaOrgNutritionInformation,\n SchemaOrgRecipe,\n ToJsonLdOptions,\n ToJsonLdResult,\n} from './types'\n"],"mappings":";AAAA,SAAS,wBAAwB;AACjC,SAAS,kBAAkB;AAC3B,SAAS,gBAAgB;","names":[]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/steps/segment.ts
|
|
2
|
+
import { describeValue, RecipeBookError } from "../errors.js";
|
|
3
|
+
import { matchTemperature } from "./temperature.js";
|
|
4
|
+
function segmentStepText(text, options) {
|
|
5
|
+
if (typeof text !== "string") {
|
|
6
|
+
throw new RecipeBookError(
|
|
7
|
+
"invalid_argument",
|
|
8
|
+
`segmentStepText: text must be a string, received ${describeValue(text)}.`
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
const assumed = options?.assumeTemperatureUnit;
|
|
12
|
+
if (assumed !== void 0 && assumed !== "C" && assumed !== "F") {
|
|
13
|
+
throw new RecipeBookError(
|
|
14
|
+
"invalid_argument",
|
|
15
|
+
`segmentStepText: assumeTemperatureUnit must be "C" or "F", received ${describeValue(assumed)}.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const segments = [];
|
|
19
|
+
const digits = /\d+/g;
|
|
20
|
+
let done = 0;
|
|
21
|
+
for (let found = digits.exec(text); found; found = digits.exec(text)) {
|
|
22
|
+
const match = matchTemperature(text, found.index, done, assumed);
|
|
23
|
+
if (!match) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (match.start > done) {
|
|
27
|
+
segments.push({ type: "text", text: text.slice(done, match.start) });
|
|
28
|
+
}
|
|
29
|
+
segments.push({
|
|
30
|
+
type: "temperature",
|
|
31
|
+
text: text.slice(match.start, match.end),
|
|
32
|
+
...match.reading,
|
|
33
|
+
alternatives: match.alternatives
|
|
34
|
+
});
|
|
35
|
+
done = digits.lastIndex = match.end;
|
|
36
|
+
}
|
|
37
|
+
if (done < text.length) {
|
|
38
|
+
segments.push({ type: "text", text: text.slice(done) });
|
|
39
|
+
}
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
segmentStepText
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=segment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/steps/segment.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport type { Temperature, TemperatureUnit } from '../units/temperature'\nimport { matchTemperature } from './temperature'\n\n/** A temperature read from step text, and whether it is for a fan oven. */\nexport type TemperatureReading = {\n temperature: Temperature\n /**\n * True when the text marks it for a fan or convection oven, as in \"170C\n * fan\". A marker after a segment's closing parenthesis, as in \"170°C (about\n * 340°F) fan\", marks every reading in the segment. An unmarked reading in\n * parentheses that restates a marked one, in the other unit and within 10 °C,\n * is marked too, as in \"170°C fan (about 340°F)\". In \"160°C fan (350°F)\",\n * 350 °F is 177 °C, so it isn't. Otherwise, a marker with other words between\n * it and the reading, as in \"325°F for convection\", isn't read.\n */\n fan: boolean\n}\n\n/** Step text with nothing recognized in it. */\nexport type TextSegment = { type: 'text'; text: string }\n\n/**\n * A temperature in step text. When the author already wrote the setting more\n * than one way, as in \"400 degrees F (200 degrees C)\" or \"190C/170C fan/gas 5\",\n * the whole run is one segment: the first reading is `temperature`, the others\n * are `alternatives`, and `text` keeps any gas mark uninterpreted. Readings\n * with the same `fan` value are one setting in both units, within 10 °C of\n * each other, so no two share a unit and a `fan` value.\n */\nexport type TemperatureSegment = TemperatureReading & {\n type: 'temperature'\n /** The text exactly as authored, such as `'350°F'` or `'200C/180C fan forced'`. */\n text: string\n /** The other readings in this segment, in authored order. Often empty. */\n alternatives: TemperatureReading[]\n}\n\n/**\n * A piece of step text. Recognizes temperatures in 0.1; durations and other\n * kinds may be added in minor releases, so handle unknown `type`s as text.\n */\nexport type StepSegment = TextSegment | TemperatureSegment\n\n/** Options for {@link segmentStepText}. */\nexport type SegmentStepTextOptions = {\n /**\n * Scale for a temperature written with a degree sign or the word \"degree\" or\n * \"degrees\" but no scale, such as \"Bake at 350 degrees\", \"Bake in a\n * 350-degree oven.\" or \"Preheat oven to 350°.\" Default: none, so those stay\n * text. A number with no degree sign or word, as in \"Preheat oven to 350.\",\n * stays text even with a scale. With one, such a temperature counts only\n * after a word such as \"at\" or \"to\", before \"oven\", or when a reading in the\n * other unit restates it, as in \"350° (175°C)\", so \"rotate the pan 180\n * degrees\" stays text.\n */\n assumeTemperatureUnit?: TemperatureUnit | undefined\n}\n\n/**\n * Splits step text into plain text and the temperatures in it. Joining every\n * segment's `text` gives back the input exactly.\n *\n * @throws {RecipeBookError} `invalid_argument` for text that isn't a string or an unknown `assumeTemperatureUnit`.\n *\n * @example\n * segmentStepText('Bake at 350°F for 20 minutes.')\n * // [\n * // { type: 'text', text: 'Bake at ' },\n * // { type: 'temperature', text: '350°F', temperature: { value: 350, unit: 'F' }, fan: false, alternatives: [] },\n * // { type: 'text', text: ' for 20 minutes.' },\n * // ]\n */\nexport function segmentStepText(\n text: string,\n options?: SegmentStepTextOptions,\n): StepSegment[] {\n if (typeof text !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `segmentStepText: text must be a string, received ${describeValue(text)}.`,\n )\n }\n\n const assumed = options?.assumeTemperatureUnit\n if (assumed !== undefined && assumed !== 'C' && assumed !== 'F') {\n throw new RecipeBookError(\n 'invalid_argument',\n `segmentStepText: assumeTemperatureUnit must be \"C\" or \"F\", received ${describeValue(assumed)}.`,\n )\n }\n\n const segments: StepSegment[] = []\n const digits = /\\d+/g\n let done = 0\n for (let found = digits.exec(text); found; found = digits.exec(text)) {\n const match = matchTemperature(text, found.index, done, assumed)\n if (!match) {\n continue\n }\n\n if (match.start > done) {\n segments.push({ type: 'text', text: text.slice(done, match.start) })\n }\n\n segments.push({\n type: 'temperature',\n text: text.slice(match.start, match.end),\n ...match.reading,\n alternatives: match.alternatives,\n })\n done = digits.lastIndex = match.end\n }\n\n if (done < text.length) {\n segments.push({ type: 'text', text: text.slice(done) })\n }\n\n return segments\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAE/C,SAAS,wBAAwB;AAuE1B,SAAS,gBACd,MACA,SACe;AACf,MAAI,OAAO,SAAS,UAAU;AAC5B,UAAM,IAAI;AAAA,MACR;AAAA,MACA,oDAAoD,cAAc,IAAI,CAAC;AAAA,IACzE;AAAA,EACF;AAEA,QAAM,UAAU,SAAS;AACzB,MAAI,YAAY,UAAa,YAAY,OAAO,YAAY,KAAK;AAC/D,UAAM,IAAI;AAAA,MACR;AAAA,MACA,uEAAuE,cAAc,OAAO,CAAC;AAAA,IAC/F;AAAA,EACF;AAEA,QAAM,WAA0B,CAAC;AACjC,QAAM,SAAS;AACf,MAAI,OAAO;AACX,WAAS,QAAQ,OAAO,KAAK,IAAI,GAAG,OAAO,QAAQ,OAAO,KAAK,IAAI,GAAG;AACpE,UAAM,QAAQ,iBAAiB,MAAM,MAAM,OAAO,MAAM,OAAO;AAC/D,QAAI,CAAC,OAAO;AACV;AAAA,IACF;AAEA,QAAI,MAAM,QAAQ,MAAM;AACtB,eAAS,KAAK,EAAE,MAAM,QAAQ,MAAM,KAAK,MAAM,MAAM,MAAM,KAAK,EAAE,CAAC;AAAA,IACrE;AAEA,aAAS,KAAK;AAAA,MACZ,MAAM;AAAA,MACN,MAAM,KAAK,MAAM,MAAM,OAAO,MAAM,GAAG;AAAA,MACvC,GAAG,MAAM;AAAA,MACT,cAAc,MAAM;AAAA,IACtB,CAAC;AACD,WAAO,OAAO,YAAY,MAAM;AAAA,EAClC;AAEA,MAAI,OAAO,KAAK,QAAQ;AACtB,aAAS,KAAK,EAAE,MAAM,QAAQ,MAAM,KAAK,MAAM,IAAI,EAAE,CAAC;AAAA,EACxD;AAEA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/steps/temperature.ts
|
|
2
|
+
var LOOKBACK = 24;
|
|
3
|
+
var MAX_ITEMS = 6;
|
|
4
|
+
var NUMBER = /(\d{1,4})(\.\d{1,2})?(?![.,]?\d)/y;
|
|
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
|
+
var FAN_GROUP = /\s{0,3}\(\s{0,2}(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\s{0,2}\)/iy;
|
|
9
|
+
var FAN_BEFORE = /(?:(?:fan|convection)(?:[- ](?:forced|assisted|oven))?|umluft|hei(?:ß|ss)luft)\s{1,3}/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
|
+
var SEPARATOR = /\s{0,3}(?:(?:,?\s{0,3}or(?=\s)|[-/,\u2013\u2014])\s{0,3})?/iy;
|
|
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
|
+
var CLOSE = /\s{0,2}\)/y;
|
|
15
|
+
var OVEN_STEP = 10;
|
|
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
|
+
var GAS_ALTERNATIVE = /(?:gas|mark)\s{0,3}\d{1,2}\s{0,3}(?:\/|or)\s{0,3}$/i;
|
|
18
|
+
var RANGE_AFTER = /\s{0,3}(?:[-\u2010-\u2015~/&]|to|and|or)\s{0,3}-?\d/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
|
+
var SIGN = /[-\u2212]/;
|
|
23
|
+
var SIGN_BEFORE = /[\s(]/;
|
|
24
|
+
var LETTER = /[a-z]/i;
|
|
25
|
+
function matchTemperature(text, digit, floor, assumed) {
|
|
26
|
+
const negative = digit > floor && SIGN.test(text[digit - 1] ?? "") && SIGN_BEFORE.test(text.charAt(digit - 2) || " ");
|
|
27
|
+
const start = negative ? digit - 1 : digit;
|
|
28
|
+
const before = text.slice(Math.max(floor, start - LOOKBACK), start);
|
|
29
|
+
if (LETTER.test(text.charAt(start - 1)) || NOT_A_READING.test(before) || SHARED.test(before) && !GAS_ALTERNATIVE.test(before)) {
|
|
30
|
+
return void 0;
|
|
31
|
+
}
|
|
32
|
+
const first = readingAt(text, digit);
|
|
33
|
+
if (!first) {
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
const prefix = FAN_PREFIX.exec(before)?.[1];
|
|
37
|
+
const unit = first.unit ?? assumed;
|
|
38
|
+
if (!unit || !first.unit && exec(RANGE_AFTER, text, first.end)) {
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
const reading = {
|
|
42
|
+
temperature: {
|
|
43
|
+
value: negative && first.value ? -first.value : first.value,
|
|
44
|
+
unit
|
|
45
|
+
},
|
|
46
|
+
fan: Boolean(prefix) || first.fan
|
|
47
|
+
};
|
|
48
|
+
const run = { readings: [reading], gas: false };
|
|
49
|
+
let end = extend(text, first.end, run);
|
|
50
|
+
if (first.cued && run.readings.length < 2 && !(prefix || SETTING.test(before) || exec(OVEN_AFTER, text, first.end))) {
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
const fanEnd = text[end - 1] === ")" && !run.readings.some(({ fan }) => fan) ? at(FAN_AFTER, text, end) : void 0;
|
|
54
|
+
if (fanEnd !== void 0) {
|
|
55
|
+
end = fanEnd;
|
|
56
|
+
for (const item of run.readings) {
|
|
57
|
+
item.fan = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
start: prefix ? start - prefix.length : start,
|
|
62
|
+
end,
|
|
63
|
+
reading,
|
|
64
|
+
alternatives: run.readings.slice(1)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function extend(text, end, run, grouped) {
|
|
68
|
+
let owner;
|
|
69
|
+
for (let count = 1; count < MAX_ITEMS; count++) {
|
|
70
|
+
run.clash = false;
|
|
71
|
+
let group;
|
|
72
|
+
if (!grouped) {
|
|
73
|
+
const { readings, gas } = run;
|
|
74
|
+
const { length } = readings;
|
|
75
|
+
const first = itemAt(text, at(OPEN, text, end), run);
|
|
76
|
+
group = first === void 0 ? void 0 : at(CLOSE, text, extend(text, first, run, true));
|
|
77
|
+
if (group !== void 0) {
|
|
78
|
+
const fanOwner = readings[length - 1];
|
|
79
|
+
for (const item of readings.slice(length)) {
|
|
80
|
+
const { unit } = item.temperature;
|
|
81
|
+
if (fanOwner?.fan && near(item.temperature, fanOwner.temperature) && !readings.some(
|
|
82
|
+
(other) => other.fan && other.temperature.unit === unit
|
|
83
|
+
)) {
|
|
84
|
+
item.fan = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
readings.length = length;
|
|
89
|
+
run.gas = gas;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (run.clash && owner) {
|
|
93
|
+
run.readings.length = owner[1];
|
|
94
|
+
return owner[0];
|
|
95
|
+
}
|
|
96
|
+
const size = run.readings.length;
|
|
97
|
+
const next = group ?? itemAt(text, at(SEPARATOR, text, end), run);
|
|
98
|
+
if (next === void 0) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
owner = run.readings.length > size ? [end, size] : void 0;
|
|
102
|
+
end = next;
|
|
103
|
+
}
|
|
104
|
+
return end;
|
|
105
|
+
}
|
|
106
|
+
function itemAt(text, index, run) {
|
|
107
|
+
if (index === void 0) {
|
|
108
|
+
return void 0;
|
|
109
|
+
}
|
|
110
|
+
const reading = readingAt(text, index);
|
|
111
|
+
if (reading?.unit) {
|
|
112
|
+
const { value, unit, fan } = reading;
|
|
113
|
+
const temperature = { value, unit };
|
|
114
|
+
if (run.readings.some(
|
|
115
|
+
(item) => item.fan === fan && (item.temperature.unit === unit || !near(item.temperature, temperature))
|
|
116
|
+
)) {
|
|
117
|
+
run.clash = true;
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
run.readings.push({ temperature, fan });
|
|
121
|
+
return reading.end;
|
|
122
|
+
}
|
|
123
|
+
const end = run.gas ? void 0 : at(GAS, text, index);
|
|
124
|
+
if (end !== void 0) {
|
|
125
|
+
run.gas = true;
|
|
126
|
+
}
|
|
127
|
+
return end;
|
|
128
|
+
}
|
|
129
|
+
function readingAt(text, index) {
|
|
130
|
+
const marked = at(FAN_BEFORE, text, index);
|
|
131
|
+
const number = exec(NUMBER, text, marked ?? index);
|
|
132
|
+
if (!number) {
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
const [digits, integer = "", fraction] = number;
|
|
136
|
+
const after = number.index + digits.length;
|
|
137
|
+
let scale = exec(DEGREES, text, after);
|
|
138
|
+
let name = scale && (scale[1] ?? scale[2] ?? scale[3]);
|
|
139
|
+
let cups = false;
|
|
140
|
+
if (!scale) {
|
|
141
|
+
scale = exec(SCALE, text, after);
|
|
142
|
+
name = scale?.[2];
|
|
143
|
+
cups = name === "c" || name === "C" && Boolean(scale?.[1]);
|
|
144
|
+
if (!scale || name?.length === 1 && (fraction || integer.length < (cups || name === "f" ? 3 : 2))) {
|
|
145
|
+
return void 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const end = scale.index + scale[0].length;
|
|
149
|
+
const fanEnd = at(FAN_AFTER, text, end) ?? at(FAN_GROUP, text, end);
|
|
150
|
+
return {
|
|
151
|
+
value: Number(digits),
|
|
152
|
+
unit: name ? /^[cс℃]/i.test(name) ? "C" : "F" : void 0,
|
|
153
|
+
fan: marked !== void 0 || fanEnd !== void 0,
|
|
154
|
+
cued: !name || cups,
|
|
155
|
+
end: fanEnd ?? end
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function near(a, b) {
|
|
159
|
+
return Math.abs(celsius(a) - celsius(b)) <= OVEN_STEP;
|
|
160
|
+
}
|
|
161
|
+
function celsius({ value, unit }) {
|
|
162
|
+
return unit === "C" ? value : (value - 32) / 1.8;
|
|
163
|
+
}
|
|
164
|
+
function exec(pattern, text, index) {
|
|
165
|
+
pattern.lastIndex = index;
|
|
166
|
+
return pattern.exec(text);
|
|
167
|
+
}
|
|
168
|
+
function at(pattern, text, index) {
|
|
169
|
+
return exec(pattern, text, index) ? pattern.lastIndex : void 0;
|
|
170
|
+
}
|
|
171
|
+
export {
|
|
172
|
+
matchTemperature
|
|
173
|
+
};
|
|
174
|
+
//# sourceMappingURL=temperature.js.map
|
|
@@ -0,0 +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// 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":"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","names":[]}
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/suggest.ts
|
|
2
|
+
function closestMatch(text, candidates) {
|
|
3
|
+
const input = text.trim().toLowerCase();
|
|
4
|
+
const maxDistance = input.length < 3 ? 0 : input.length < 6 ? 1 : 2;
|
|
5
|
+
let best;
|
|
6
|
+
let bestDistance = Infinity;
|
|
7
|
+
let bestGap = 0;
|
|
8
|
+
for (const candidate of candidates) {
|
|
9
|
+
const lengthGap = Math.abs(candidate.length - input.length);
|
|
10
|
+
if (lengthGap > maxDistance) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const distance = editDistance(input, candidate.toLowerCase());
|
|
14
|
+
if (distance <= maxDistance && (distance < bestDistance || distance === bestDistance && lengthGap < bestGap)) {
|
|
15
|
+
best = candidate;
|
|
16
|
+
bestDistance = distance;
|
|
17
|
+
bestGap = lengthGap;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
function editDistance(a, b) {
|
|
23
|
+
let twoAbove = [];
|
|
24
|
+
let above = [0];
|
|
25
|
+
for (let j = 1; j <= b.length; j++) {
|
|
26
|
+
above[j] = j;
|
|
27
|
+
}
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
const row = [i];
|
|
30
|
+
for (let j = 1; j <= b.length; j++) {
|
|
31
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
32
|
+
let distance = Math.min(
|
|
33
|
+
above[j] + 1,
|
|
34
|
+
row[j - 1] + 1,
|
|
35
|
+
above[j - 1] + cost
|
|
36
|
+
);
|
|
37
|
+
if (a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
38
|
+
distance = Math.min(distance, twoAbove[j - 2] + 0.5);
|
|
39
|
+
}
|
|
40
|
+
row[j] = distance;
|
|
41
|
+
}
|
|
42
|
+
twoAbove = above;
|
|
43
|
+
above = row;
|
|
44
|
+
}
|
|
45
|
+
return above[b.length];
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
closestMatch
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=suggest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/suggest.ts"],"sourcesContent":["/**\n * The candidate closest to misspelled text, for \"did you mean\" hints. Case is\n * ignored. Text under 3 characters must match apart from case; text under 6\n * allows one edit, and longer text allows two. Ties go to the candidate nearest\n * in length, then to the earliest.\n */\nexport function closestMatch<Candidate extends string>(\n text: string,\n candidates: Iterable<Candidate>,\n): Candidate | undefined {\n const input = text.trim().toLowerCase()\n const maxDistance = input.length < 3 ? 0 : input.length < 6 ? 1 : 2\n\n let best: Candidate | undefined\n let bestDistance = Infinity\n let bestGap = 0\n\n for (const candidate of candidates) {\n const lengthGap = Math.abs(candidate.length - input.length)\n if (lengthGap > maxDistance) {\n continue\n }\n\n const distance = editDistance(input, candidate.toLowerCase())\n\n if (\n distance <= maxDistance &&\n (distance < bestDistance ||\n (distance === bestDistance && lengthGap < bestGap))\n ) {\n best = candidate\n bestDistance = distance\n bestGap = lengthGap\n }\n }\n\n return best\n}\n\n/**\n * Optimal string alignment distance: insertions, deletions, substitutions, and\n * adjacent swaps. A swap costs half an edit, so for \"tbps\" the swap to \"tbsp\"\n * outranks the substitution to \"tsps\".\n */\nfunction editDistance(a: string, b: string): number {\n let twoAbove: number[] = []\n let above = [0]\n for (let j = 1; j <= b.length; j++) {\n above[j] = j\n }\n\n for (let i = 1; i <= a.length; i++) {\n const row = [i]\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1\n let distance = Math.min(\n above[j]! + 1,\n row[j - 1]! + 1,\n above[j - 1]! + cost,\n )\n\n if (a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {\n distance = Math.min(distance, twoAbove[j - 2]! + 0.5)\n }\n\n row[j] = distance\n }\n twoAbove = above\n above = row\n }\n\n return above[b.length]!\n}\n"],"mappings":";AAMO,SAAS,aACd,MACA,YACuB;AACvB,QAAM,QAAQ,KAAK,KAAK,EAAE,YAAY;AACtC,QAAM,cAAc,MAAM,SAAS,IAAI,IAAI,MAAM,SAAS,IAAI,IAAI;AAElE,MAAI;AACJ,MAAI,eAAe;AACnB,MAAI,UAAU;AAEd,aAAW,aAAa,YAAY;AAClC,UAAM,YAAY,KAAK,IAAI,UAAU,SAAS,MAAM,MAAM;AAC1D,QAAI,YAAY,aAAa;AAC3B;AAAA,IACF;AAEA,UAAM,WAAW,aAAa,OAAO,UAAU,YAAY,CAAC;AAE5D,QACE,YAAY,gBACX,WAAW,gBACT,aAAa,gBAAgB,YAAY,UAC5C;AACA,aAAO;AACP,qBAAe;AACf,gBAAU;AAAA,IACZ;AAAA,EACF;AAEA,SAAO;AACT;AAOA,SAAS,aAAa,GAAW,GAAmB;AAClD,MAAI,WAAqB,CAAC;AAC1B,MAAI,QAAQ,CAAC,CAAC;AACd,WAAS,IAAI,GAAG,KAAK,EAAE,QAAQ,KAAK;AAClC,UAAM,CAAC,IAAI;AAAA,EACb;AAEA,WAAS,IAAI,GAAG,KAAK,EAAE,QAAQ,KAAK;AAClC,UAAM,MAAM,CAAC,CAAC;AACd,aAAS,IAAI,GAAG,KAAK,EAAE,QAAQ,KAAK;AAClC,YAAM,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,IAAI;AACzC,UAAI,WAAW,KAAK;AAAA,QAClB,MAAM,CAAC,IAAK;AAAA,QACZ,IAAI,IAAI,CAAC,IAAK;AAAA,QACd,MAAM,IAAI,CAAC,IAAK;AAAA,MAClB;AAEA,UAAI,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG;AAClD,mBAAW,KAAK,IAAI,UAAU,SAAS,IAAI,CAAC,IAAK,GAAG;AAAA,MACtD;AAEA,UAAI,CAAC,IAAI;AAAA,IACX;AACA,eAAW;AACX,YAAQ;AAAA,EACV;AAEA,SAAO,MAAM,EAAE,MAAM;AACvB;","names":[]}
|