@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.
- package/CHANGELOG.md +103 -0
- package/README.md +1 -1
- package/dist/{chunk-VWVIBCTG.cjs → chunk-D5CV3UF7.cjs} +188 -173
- package/dist/chunk-D5CV3UF7.cjs.map +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/format/number.js +1 -1
- package/dist/format/number.js.map +1 -1
- package/dist/format/options.js.map +1 -1
- package/dist/index.cjs +83 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -19
- package/dist/index.d.ts +35 -19
- package/dist/ingredient/parse.js +27 -22
- package/dist/ingredient/parse.js.map +1 -1
- package/dist/issues.js.map +1 -1
- package/dist/quantity/amount.js +11 -0
- package/dist/quantity/amount.js.map +1 -1
- package/dist/quantity/parse.js +58 -81
- package/dist/quantity/parse.js.map +1 -1
- package/dist/quantity/scale.js +5 -2
- package/dist/quantity/scale.js.map +1 -1
- package/dist/recipe/parse.js +70 -55
- package/dist/recipe/parse.js.map +1 -1
- package/dist/recipe/schema.js +8 -6
- package/dist/recipe/schema.js.map +1 -1
- package/dist/resolve.js +6 -1
- package/dist/resolve.js.map +1 -1
- package/dist/schema-org/find.js +46 -13
- package/dist/schema-org/find.js.map +1 -1
- package/dist/schema-org/from-json-ld.js +127 -57
- package/dist/schema-org/from-json-ld.js.map +1 -1
- package/dist/schema-org/text.js.map +1 -1
- package/dist/schema-org/to-json-ld.js +12 -15
- package/dist/schema-org/to-json-ld.js.map +1 -1
- package/dist/schema-org.cjs +231 -132
- package/dist/schema-org.cjs.map +1 -1
- package/dist/schema-org.d.cts +32 -19
- package/dist/schema-org.d.ts +32 -19
- package/dist/steps/temperature.js +13 -13
- package/dist/steps/temperature.js.map +1 -1
- package/dist/{types-DSMf45H5.d.cts → types-BknM8KOd.d.cts} +4 -3
- package/dist/{types-DSMf45H5.d.ts → types-BknM8KOd.d.ts} +4 -3
- package/dist/units/convert.js +16 -8
- package/dist/units/convert.js.map +1 -1
- package/dist/units/temperature.js +7 -1
- package/dist/units/temperature.js.map +1 -1
- package/dist/validation.js +1 -1
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-VWVIBCTG.cjs.map +0 -1
package/dist/schema-org/find.js
CHANGED
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
// src/schema-org/find.ts
|
|
2
2
|
import { describeValue, RecipeBookError } from "../errors.js";
|
|
3
|
-
import { isRecord } from "../issues.js";
|
|
3
|
+
import { hasOwn, isRecord } from "../issues.js";
|
|
4
4
|
import { asRecord } from "../validation.js";
|
|
5
5
|
var MAX_DEPTH = 32;
|
|
6
6
|
function locate(documents) {
|
|
7
|
+
const direct = scan(documents, false);
|
|
8
|
+
return direct.recipes.length > 0 ? direct : scan(documents, true);
|
|
9
|
+
}
|
|
10
|
+
function scan(documents, lists) {
|
|
7
11
|
const recipes = [];
|
|
8
12
|
const ids = /* @__PURE__ */ new Map();
|
|
9
13
|
const recipeIds = /* @__PURE__ */ new Map();
|
|
10
|
-
const visit = (value, path,
|
|
14
|
+
const visit = (value, path, reach = "any") => {
|
|
11
15
|
if (path.length > MAX_DEPTH) {
|
|
12
16
|
return;
|
|
13
17
|
}
|
|
14
18
|
if (Array.isArray(value)) {
|
|
15
|
-
value
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
for (const [entry, entryPath] of entriesOf(value, path)) {
|
|
20
|
+
visit(entry, entryPath, reach);
|
|
21
|
+
}
|
|
18
22
|
return;
|
|
19
23
|
}
|
|
20
24
|
const fields = asRecord(value);
|
|
21
25
|
if (!fields) {
|
|
22
26
|
return;
|
|
23
27
|
}
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
const isRecipe = hasType(fields, "Recipe");
|
|
29
|
+
const isList = lists && hasType(fields, "ItemList");
|
|
30
|
+
if (reach !== "any" && !isRecipe) {
|
|
31
|
+
if (isList && reach === "mainEntity") {
|
|
32
|
+
visit(fields.itemListElement, [...path, "itemListElement"], "entry");
|
|
33
|
+
} else if (lists && reach === "entry" && hasType(fields, "ListItem")) {
|
|
34
|
+
visit(fields.item, [...path, "item"], "item");
|
|
35
|
+
}
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
31
38
|
const located = { node: value, path };
|
|
@@ -46,7 +53,10 @@ function locate(documents) {
|
|
|
46
53
|
}
|
|
47
54
|
} else {
|
|
48
55
|
visit(fields["@graph"], [...path, "@graph"]);
|
|
49
|
-
visit(fields.mainEntity, [...path, "mainEntity"],
|
|
56
|
+
visit(fields.mainEntity, [...path, "mainEntity"], "mainEntity");
|
|
57
|
+
if (isList) {
|
|
58
|
+
visit(fields.itemListElement, [...path, "itemListElement"], "entry");
|
|
59
|
+
}
|
|
50
60
|
}
|
|
51
61
|
};
|
|
52
62
|
for (const document of documents) {
|
|
@@ -54,8 +64,30 @@ function locate(documents) {
|
|
|
54
64
|
}
|
|
55
65
|
return { recipes, ids };
|
|
56
66
|
}
|
|
67
|
+
var TYPES = {
|
|
68
|
+
Recipe: /(?:^|[/:#])Recipe$/,
|
|
69
|
+
ItemList: /(?:^|[/:#])ItemList$/,
|
|
70
|
+
ListItem: /(?:^|[/:#])ListItem$/
|
|
71
|
+
};
|
|
72
|
+
function hasType(node, name) {
|
|
73
|
+
return entriesOf(node["@type"], []).some(
|
|
74
|
+
([type]) => typeof type === "string" && TYPES[name].test(type)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
57
77
|
function hasContent(node) {
|
|
58
|
-
return Object.keys(node).some((key) => key
|
|
78
|
+
return Object.keys(node).some((key) => !key.startsWith("@"));
|
|
79
|
+
}
|
|
80
|
+
function entriesOf(value, path) {
|
|
81
|
+
if (!Array.isArray(value)) {
|
|
82
|
+
return [[value, path]];
|
|
83
|
+
}
|
|
84
|
+
const entries = [];
|
|
85
|
+
for (let i = 0; i < value.length; i++) {
|
|
86
|
+
if (hasOwn(value, i)) {
|
|
87
|
+
entries.push([value[i], [...path, i]]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return entries;
|
|
59
91
|
}
|
|
60
92
|
function findRecipeJsonLd(html) {
|
|
61
93
|
if (typeof html !== "string") {
|
|
@@ -84,7 +116,7 @@ function findRecipeJsonLd(html) {
|
|
|
84
116
|
}
|
|
85
117
|
function withImages(node, ids) {
|
|
86
118
|
const follow = (value) => {
|
|
87
|
-
const id = isRecord(value) && Object.keys(value).
|
|
119
|
+
const id = isRecord(value) && Object.keys(value).join() === "@id" && value["@id"];
|
|
88
120
|
const target = typeof id === "string" && ids.get(id)?.node;
|
|
89
121
|
return target && target !== node ? target : value;
|
|
90
122
|
};
|
|
@@ -119,6 +151,7 @@ function parseJson(text) {
|
|
|
119
151
|
}
|
|
120
152
|
export {
|
|
121
153
|
MAX_DEPTH,
|
|
154
|
+
entriesOf,
|
|
122
155
|
findRecipeJsonLd,
|
|
123
156
|
locate
|
|
124
157
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/schema-org/find.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { isRecord, type Path } from '../issues'\nimport { asRecord } from '../validation'\n\n/** A JSON-LD node, as passed in, and its path in the input. */\nexport type Located = { node: Record<string, unknown>; path: Path }\n\n/**\n * The longest path read in JSON-LD. Real markup nests about ten levels, and\n * the bound keeps a hostile page from overflowing the stack.\n */\nexport const MAX_DEPTH = 32\n\n/**\n * Finds the Recipe nodes in parsed JSON-LD documents, in order, through arrays,\n * `@graph`, and one level of `mainEntity`. It indexes by `@id` each Recipe, and\n * each node at the top level of the JSON-LD, or reached from there through\n * arrays and `@graph`, so references can be followed. An `@id` names its first\n * node, and a Recipe with the `@id` of an earlier Recipe is left out, unless\n * the earlier one holds nothing but keywords such as `@type`: then it takes\n * that one's place in `recipes`, and in `ids` when that one was the first node\n * with its `@id`. An empty `@id` is no `@id`.\n */\nexport function locate(documents: readonly unknown[]): {\n recipes: Located[]\n ids: Map<string, Located>\n} {\n const recipes: Located[] = []\n const ids = new Map<string, Located>()\n // A stub Recipe, or `true` once the `@id`'s Recipe holds content, so a\n // duplicate never rescans the first one's keys.\n const recipeIds = new Map<unknown, Located | true>()\n\n const visit = (value: unknown, path: Path, mainEntity = false): void => {\n if (path.length > MAX_DEPTH) {\n return\n }\n\n if (Array.isArray(value)) {\n value.forEach((entry: unknown, i) =>\n visit(entry, [...path, i], mainEntity),\n )\n return\n }\n\n const fields = asRecord(value)\n if (!fields) {\n return\n }\n\n const type = fields['@type']\n const isRecipe = [type]\n .flat()\n .some(\n (name) => typeof name === 'string' && /(?:^|[/:#])Recipe$/.test(name),\n )\n\n if (mainEntity && !isRecipe) {\n return\n }\n\n const located = { node: value as Record<string, unknown>, path }\n const id = fields['@id'] || undefined\n if (typeof id === 'string' && !ids.has(id)) {\n ids.set(id, located)\n }\n\n if (isRecipe) {\n const earlier = recipeIds.get(id)\n if (!earlier) {\n recipes.push(located)\n if (typeof id === 'string') {\n recipeIds.set(id, hasContent(fields) || located)\n }\n } else if (earlier !== true && hasContent(fields)) {\n // `recipes` holds this same object, and so does `ids` when the stub was\n // the first node with its `@id`, so a reference to the recipe still\n // finds the recipe itself.\n Object.assign(earlier, located)\n recipeIds.set(id, true)\n }\n } else {\n visit(fields['@graph'], [...path, '@graph'])\n visit(fields.mainEntity, [...path, 'mainEntity'], true)\n }\n }\n\n for (const document of documents) {\n visit(document, [])\n }\n return { recipes, ids }\n}\n\nfunction hasContent(node: Record<string, unknown>): boolean {\n return Object.keys(node).some((key) => key[0] !== '@')\n}\n\n/**\n * Finds the schema.org Recipe nodes in a page's\n * `<script type=\"application/ld+json\">` tags, without a DOM. Pass one to\n * `fromJsonLd`.\n *\n * Reads arrays and `@graph`, as Yoast and WP Recipe Maker write them, and a\n * Recipe in a node's `mainEntity`, and matches a node whose `@type` is or\n * includes `\"Recipe\"`. A tag whose JSON doesn't parse is retried with the raw\n * line breaks and tabs inside its strings escaped, as real pages have them,\n * and skipped if it still doesn't. A tag inside an HTML comment is skipped,\n * and a `<!--` in an attribute value or in a `style` or `textarea` element is\n * text, as in a browser. A quote starts an attribute value only right after\n * `=`, so a stray one, as in `content=\"a 9\" pan\"`, doesn't hide later tags. A\n * Recipe node with the `@id` of an earlier one is returned once, where the\n * first appears. When the first holds only keywords, such as a `mainEntity`\n * of `{ \"@type\": \"Recipe\", \"@id\": \"#recipe\" }`, the later node is returned in\n * its place. An empty `@id` is no `@id`, so it merges nothing.\n *\n * In each Recipe node returned, an image that is an `@id` reference to a node\n * at the top level of the JSON-LD, or reached from there through arrays and\n * `@graph`, holds that node instead of the reference, an object that holds only\n * that `@id`. That applies to the recipe's `image` and each step's `image` in\n * `recipeInstructions`, and lets `fromJsonLd` read them. A reference to a node\n * in any other field, such as an `Organization`'s `logo` or an `ImageObject` in\n * a `WebPage`'s `mainEntity`, isn't followed. Other references, such as\n * `author` and `mainEntityOfPage`, and references inside the node an image\n * holds, stay as the page wrote them.\n *\n * @param html - The page's HTML, such as `await response.text()`.\n * @returns Every Recipe node, in document order, or `[]` when there are none.\n * @throws {RecipeBookError} `invalid_argument` when `html` isn't a string.\n *\n * @example\n * const [node] = findRecipeJsonLd(await response.text())\n * const result = fromJsonLd(node)\n */\nexport function findRecipeJsonLd(html: string): Record<string, unknown>[] {\n if (typeof html !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `findRecipeJsonLd: html must be a string, such as await response.text(), received ${describeValue(html)}.`,\n )\n }\n\n const documents: unknown[] = []\n\n // At each `<`, in this order: a script, style, or textarea element with its\n // content; a comment, where `<!-->` and `<!--->` close at once; or any other\n // tag with its quoted attribute values. What one of these holds, such as a\n // `<!--` in an attribute value, starts nothing, as in a browser. A quote\n // starts a value only right after `=`, so a stray one, as in\n // `content=\"a 9\" pan\"`, is text. The element's attributes are read once, in\n // a lookahead, then matched by backreference, so a tag with no `>` fails\n // without retrying other splits of them, which would be exponential.\n for (const [, element, attributes = '', body = ''] of html.matchAll(\n /<(script|style|textarea)(?=[\\s/>])(?=((?:=\\s*(?:\"[^\"]*\"|'[^']*')|[^<>])*))\\2>([\\s\\S]*?)(?:<\\/\\1\\s*>|$)|<!--(?:-?>|[\\s\\S]*?(?:--!?>|$))|<\\/?[a-z][^\\s/>]*(?:=\\s*(?:\"[^\"]*\"|'[^']*')|[^<>])*/gi,\n )) {\n if (\n element?.toLowerCase() === 'script' &&\n /[\\s/]type\\s*=\\s*[\"']?application\\/ld\\+json\\b/i.test(attributes)\n ) {\n const text = body\n .replace(/^\\s*(?:\\/\\/\\s*)?(?:<!--|<!\\[CDATA\\[)/, '')\n .replace(/(?:\\/\\/\\s*)?(?:-->|\\]\\]>)\\s*$/, '')\n const json =\n parseJson(text) ??\n parseJson(\n text.replace(/\"(?:[^\"\\\\]|\\\\[\\s\\S]?)*(?:\"|$)/g, (string) =>\n string.replace(/[\\n\\r\\t]/g, (c) => JSON.stringify(c).slice(1, -1)),\n ),\n )\n\n documents.push(json)\n }\n }\n\n const { recipes, ids } = locate(documents)\n return recipes.map(({ node }) => withImages(node, ids))\n}\n\n/**\n * A copy of a Recipe node with each image that is a one-hop reference to\n * another node replaced by that node: the recipe's `image`, and each step's\n * `image` in `recipeInstructions`, through sections, `ItemList`s, and\n * `ListItem`s. The fields `fromJsonLd` doesn't read keep their references.\n */\nfunction withImages(\n node: Record<string, unknown>,\n ids: Map<string, Located>,\n): Record<string, unknown> {\n const follow = (value: unknown): unknown => {\n const id =\n isRecord(value) && Object.keys(value).length === 1 && value['@id']\n const target = typeof id === 'string' && ids.get(id)?.node\n return target && target !== node ? target : value\n }\n const images = (value: unknown): unknown =>\n Array.isArray(value) ? value.map(follow) : follow(value)\n const copy = (value: unknown, depth: number, keys: string[]): unknown => {\n if (depth > MAX_DEPTH) {\n return value\n }\n if (Array.isArray(value)) {\n return value.map((entry: unknown) => copy(entry, depth + 1, keys))\n }\n\n const fields = asRecord(value)\n if (!fields) {\n return value\n }\n\n const result = { ...fields }\n for (const key of keys) {\n if (fields[key] !== undefined) {\n result[key] =\n key === 'image'\n ? images(fields[key])\n : copy(fields[key], depth + 1, ['itemListElement', 'item', 'image'])\n }\n }\n return result\n }\n\n return copy(node, 0, ['image', 'recipeInstructions']) as typeof node\n}\n\nfunction parseJson(text: string): unknown {\n try {\n return JSON.parse(text)\n } catch {\n return undefined\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/schema-org/find.ts"],"sourcesContent":["import { describeValue, RecipeBookError } from '../errors'\nimport { hasOwn, isRecord, type Path } from '../issues'\nimport { asRecord } from '../validation'\n\n/** A JSON-LD node, as passed in, and its path in the input. */\nexport type Located = { node: Record<string, unknown>; path: Path }\n\n/**\n * The longest path read in JSON-LD. Real markup nests about ten levels, and\n * the bound keeps a hostile page from overflowing the stack.\n */\nexport const MAX_DEPTH = 32\n\n/**\n * Finds the Recipe nodes in parsed JSON-LD documents, in order, through arrays,\n * `@graph`, and one level of `mainEntity`, or, when that finds none, also\n * through each `ItemList`'s `itemListElement` and each `ListItem`'s `item`, to\n * `MAX_DEPTH`. It indexes by `@id` each Recipe, and each node at the top level\n * of the JSON-LD, or reached from there through arrays and `@graph`, so\n * references can be followed. An `@id` names its first node, and a Recipe\n * with the `@id` of an earlier Recipe is left out, unless the earlier one\n * holds nothing but keywords such as `@type`: then it takes that one's place\n * in `recipes`, and in `ids` when that one was the first node with its `@id`.\n * An empty `@id` is no `@id`.\n */\nexport function locate(documents: readonly unknown[]): {\n recipes: Located[]\n ids: Map<string, Located>\n} {\n const direct = scan(documents, false)\n return direct.recipes.length > 0 ? direct : scan(documents, true)\n}\n\n/** How `scan` reached a value, which decides what it reads there. */\ntype Reach = 'any' | 'mainEntity' | 'entry' | 'item'\n\nfunction scan(\n documents: readonly unknown[],\n lists: boolean,\n): { recipes: Located[]; ids: Map<string, Located> } {\n const recipes: Located[] = []\n const ids = new Map<string, Located>()\n // A stub Recipe, or `true` once the `@id`'s Recipe holds content, so a\n // duplicate never rescans the first one's keys.\n const recipeIds = new Map<unknown, Located | true>()\n\n const visit = (value: unknown, path: Path, reach: Reach = 'any'): void => {\n if (path.length > MAX_DEPTH) {\n return\n }\n\n if (Array.isArray(value)) {\n for (const [entry, entryPath] of entriesOf(value, path)) {\n visit(entry, entryPath, reach)\n }\n return\n }\n\n const fields = asRecord(value)\n if (!fields) {\n return\n }\n\n const isRecipe = hasType(fields, 'Recipe')\n const isList = lists && hasType(fields, 'ItemList')\n\n if (reach !== 'any' && !isRecipe) {\n if (isList && reach === 'mainEntity') {\n visit(fields.itemListElement, [...path, 'itemListElement'], 'entry')\n } else if (lists && reach === 'entry' && hasType(fields, 'ListItem')) {\n visit(fields.item, [...path, 'item'], 'item')\n }\n return\n }\n\n const located = { node: value as Record<string, unknown>, path }\n const id = fields['@id'] || undefined\n if (typeof id === 'string' && !ids.has(id)) {\n ids.set(id, located)\n }\n\n if (isRecipe) {\n const earlier = recipeIds.get(id)\n if (!earlier) {\n recipes.push(located)\n if (typeof id === 'string') {\n recipeIds.set(id, hasContent(fields) || located)\n }\n } else if (earlier !== true && hasContent(fields)) {\n // `recipes` holds this same object, and so does `ids` when the stub was\n // the first node with its `@id`, so a reference to the recipe still\n // finds the recipe itself.\n Object.assign(earlier, located)\n recipeIds.set(id, true)\n }\n } else {\n visit(fields['@graph'], [...path, '@graph'])\n visit(fields.mainEntity, [...path, 'mainEntity'], 'mainEntity')\n if (isList) {\n visit(fields.itemListElement, [...path, 'itemListElement'], 'entry')\n }\n }\n }\n\n for (const document of documents) {\n visit(document, [])\n }\n return { recipes, ids }\n}\n\nconst TYPES = {\n Recipe: /(?:^|[/:#])Recipe$/,\n ItemList: /(?:^|[/:#])ItemList$/,\n ListItem: /(?:^|[/:#])ListItem$/,\n}\n\nfunction hasType(\n node: Record<string, unknown>,\n name: keyof typeof TYPES,\n): boolean {\n return entriesOf(node['@type'], []).some(\n ([type]) => typeof type === 'string' && TYPES[name].test(type),\n )\n}\n\nfunction hasContent(node: Record<string, unknown>): boolean {\n return Object.keys(node).some((key) => !key.startsWith('@'))\n}\n\n/**\n * An array's elements with their paths, or a value that isn't an array with\n * its own path. A hole in an array, which a program can leave but\n * `JSON.parse` can't, is left out. It reads by index, because a program can\n * also give an array another prototype, or a `constructor` that isn't one,\n * and then array methods such as `flatMap` throw.\n */\nexport function entriesOf(value: unknown, path: Path): [unknown, Path][] {\n if (!Array.isArray(value)) {\n return [[value, path]]\n }\n const entries: [unknown, Path][] = []\n for (let i = 0; i < value.length; i++) {\n if (hasOwn(value, i)) {\n entries.push([value[i], [...path, i]])\n }\n }\n return entries\n}\n\n/**\n * Finds the schema.org Recipe nodes in a page's\n * `<script type=\"application/ld+json\">` tags, without a DOM. Pass one to\n * `fromJsonLd`.\n *\n * Reads arrays and `@graph`, as Yoast and WP Recipe Maker write them, and the\n * `mainEntity` of a node there, up to 32 levels deep, and matches a node whose\n * `@type` is or includes `\"Recipe\"`. When that finds none, it also reads each\n * `ItemList` in those places, as a roundup page lists its recipes, taking each\n * Recipe in its `itemListElement`, or in a `ListItem`'s `item` there. A\n * `ListItem` with only a `url` is skipped: fetch that page and call\n * `findRecipeJsonLd` on it. A `mainEntity`, or an entry of a `mainEntity`\n * array, that is neither a Recipe nor an `ItemList` is skipped with everything\n * in it, and a Recipe anywhere else, such as inside another Recipe or in a list\n * inside a `ListItem` or another list, isn't found.\n *\n * A tag whose JSON doesn't parse is retried with the raw line breaks and tabs\n * inside its strings escaped, as real pages have them, and skipped if it still\n * doesn't. A tag inside an HTML comment is skipped, and a `<!--` in an\n * attribute value or in a `style` or `textarea` element is text, as in a\n * browser. A quote starts an attribute value only right after `=`, so a stray\n * one, as in `content=\"a 9\" pan\"`, doesn't hide later tags. A Recipe node with\n * the `@id` of an earlier one is returned once, where the first appears. When\n * the first holds only keywords, such as a `mainEntity` of\n * `{ \"@type\": \"Recipe\", \"@id\": \"#recipe\" }`, the later node is returned in its\n * place. An empty `@id` is no `@id`, so it merges nothing.\n *\n * In each Recipe node returned, an image that is an `@id` reference to a node\n * at the top level of the JSON-LD, or reached from there through arrays and\n * `@graph`, holds that node instead of the reference, an object that holds only\n * that `@id`. That applies to the recipe's `image` and each step's `image` in\n * `recipeInstructions`, and lets `fromJsonLd` read them. A reference to a node\n * in any other field, such as an `Organization`'s `logo` or an `ImageObject` in\n * a `WebPage`'s `mainEntity`, isn't followed. Other references, such as\n * `author` and `mainEntityOfPage`, and references inside the node an image\n * holds, stay as the page wrote them.\n *\n * @param html - The page's HTML, such as `await response.text()`.\n * @returns The Recipe nodes it finds, in document order, or `[]` when there\n * are none.\n * @throws {RecipeBookError} `invalid_argument` when `html` isn't a string.\n *\n * @example\n * const [node] = findRecipeJsonLd(await response.text())\n * const result = fromJsonLd(node)\n */\nexport function findRecipeJsonLd(html: string): Record<string, unknown>[] {\n if (typeof html !== 'string') {\n throw new RecipeBookError(\n 'invalid_argument',\n `findRecipeJsonLd: html must be a string, such as await response.text(), received ${describeValue(html)}.`,\n )\n }\n\n const documents: unknown[] = []\n\n // At each `<`, in this order: a script, style, or textarea element with its\n // content; a comment, where `<!-->` and `<!--->` close at once; or any other\n // tag with its quoted attribute values. What one of these holds, such as a\n // `<!--` in an attribute value, starts nothing, as in a browser. A quote\n // starts a value only right after `=`, so a stray one, as in\n // `content=\"a 9\" pan\"`, is text. The element's attributes are read once, in\n // a lookahead, then matched by backreference, so a tag with no `>` fails\n // without retrying other splits of them, which would be exponential.\n for (const [, element, attributes = '', body = ''] of html.matchAll(\n /<(script|style|textarea)(?=[\\s/>])(?=((?:=\\s*(?:\"[^\"]*\"|'[^']*')|[^<>])*))\\2>([\\s\\S]*?)(?:<\\/\\1\\s*>|$)|<!--(?:-?>|[\\s\\S]*?(?:--!?>|$))|<\\/?[a-z][^\\s/>]*(?:=\\s*(?:\"[^\"]*\"|'[^']*')|[^<>])*/gi,\n )) {\n if (\n element?.toLowerCase() === 'script' &&\n /[\\s/]type\\s*=\\s*[\"']?application\\/ld\\+json\\b/i.test(attributes)\n ) {\n const text = body\n .replace(/^\\s*(?:\\/\\/\\s*)?(?:<!--|<!\\[CDATA\\[)/, '')\n .replace(/(?:\\/\\/\\s*)?(?:-->|\\]\\]>)\\s*$/, '')\n const json =\n parseJson(text) ??\n parseJson(\n text.replace(/\"(?:[^\"\\\\]|\\\\[\\s\\S]?)*(?:\"|$)/g, (string) =>\n string.replace(/[\\n\\r\\t]/g, (c) => JSON.stringify(c).slice(1, -1)),\n ),\n )\n\n documents.push(json)\n }\n }\n\n const { recipes, ids } = locate(documents)\n return recipes.map(({ node }) => withImages(node, ids))\n}\n\n/**\n * A copy of a Recipe node with each image that is a one-hop reference to\n * another node replaced by that node: the recipe's `image`, and each step's\n * `image` in `recipeInstructions`, through sections, `ItemList`s, and\n * `ListItem`s. The fields `fromJsonLd` doesn't read keep their references.\n */\nfunction withImages(\n node: Record<string, unknown>,\n ids: Map<string, Located>,\n): Record<string, unknown> {\n const follow = (value: unknown): unknown => {\n const id =\n isRecord(value) && Object.keys(value).join() === '@id' && value['@id']\n const target = typeof id === 'string' && ids.get(id)?.node\n return target && target !== node ? target : value\n }\n const images = (value: unknown): unknown =>\n Array.isArray(value) ? value.map(follow) : follow(value)\n const copy = (value: unknown, depth: number, keys: string[]): unknown => {\n if (depth > MAX_DEPTH) {\n return value\n }\n if (Array.isArray(value)) {\n return value.map((entry: unknown) => copy(entry, depth + 1, keys))\n }\n\n const fields = asRecord(value)\n if (!fields) {\n return value\n }\n\n const result = { ...fields }\n for (const key of keys) {\n if (fields[key] !== undefined) {\n result[key] =\n key === 'image'\n ? images(fields[key])\n : copy(fields[key], depth + 1, ['itemListElement', 'item', 'image'])\n }\n }\n return result\n }\n\n return copy(node, 0, ['image', 'recipeInstructions']) as typeof node\n}\n\nfunction parseJson(text: string): unknown {\n try {\n return JSON.parse(text)\n } catch {\n return undefined\n }\n}\n"],"mappings":";AAAA,SAAS,eAAe,uBAAuB;AAC/C,SAAS,QAAQ,gBAA2B;AAC5C,SAAS,gBAAgB;AASlB,IAAM,YAAY;AAclB,SAAS,OAAO,WAGrB;AACA,QAAM,SAAS,KAAK,WAAW,KAAK;AACpC,SAAO,OAAO,QAAQ,SAAS,IAAI,SAAS,KAAK,WAAW,IAAI;AAClE;AAKA,SAAS,KACP,WACA,OACmD;AACnD,QAAM,UAAqB,CAAC;AAC5B,QAAM,MAAM,oBAAI,IAAqB;AAGrC,QAAM,YAAY,oBAAI,IAA6B;AAEnD,QAAM,QAAQ,CAAC,OAAgB,MAAY,QAAe,UAAgB;AACxE,QAAI,KAAK,SAAS,WAAW;AAC3B;AAAA,IACF;AAEA,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,iBAAW,CAAC,OAAO,SAAS,KAAK,UAAU,OAAO,IAAI,GAAG;AACvD,cAAM,OAAO,WAAW,KAAK;AAAA,MAC/B;AACA;AAAA,IACF;AAEA,UAAM,SAAS,SAAS,KAAK;AAC7B,QAAI,CAAC,QAAQ;AACX;AAAA,IACF;AAEA,UAAM,WAAW,QAAQ,QAAQ,QAAQ;AACzC,UAAM,SAAS,SAAS,QAAQ,QAAQ,UAAU;AAElD,QAAI,UAAU,SAAS,CAAC,UAAU;AAChC,UAAI,UAAU,UAAU,cAAc;AACpC,cAAM,OAAO,iBAAiB,CAAC,GAAG,MAAM,iBAAiB,GAAG,OAAO;AAAA,MACrE,WAAW,SAAS,UAAU,WAAW,QAAQ,QAAQ,UAAU,GAAG;AACpE,cAAM,OAAO,MAAM,CAAC,GAAG,MAAM,MAAM,GAAG,MAAM;AAAA,MAC9C;AACA;AAAA,IACF;AAEA,UAAM,UAAU,EAAE,MAAM,OAAkC,KAAK;AAC/D,UAAM,KAAK,OAAO,KAAK,KAAK;AAC5B,QAAI,OAAO,OAAO,YAAY,CAAC,IAAI,IAAI,EAAE,GAAG;AAC1C,UAAI,IAAI,IAAI,OAAO;AAAA,IACrB;AAEA,QAAI,UAAU;AACZ,YAAM,UAAU,UAAU,IAAI,EAAE;AAChC,UAAI,CAAC,SAAS;AACZ,gBAAQ,KAAK,OAAO;AACpB,YAAI,OAAO,OAAO,UAAU;AAC1B,oBAAU,IAAI,IAAI,WAAW,MAAM,KAAK,OAAO;AAAA,QACjD;AAAA,MACF,WAAW,YAAY,QAAQ,WAAW,MAAM,GAAG;AAIjD,eAAO,OAAO,SAAS,OAAO;AAC9B,kBAAU,IAAI,IAAI,IAAI;AAAA,MACxB;AAAA,IACF,OAAO;AACL,YAAM,OAAO,QAAQ,GAAG,CAAC,GAAG,MAAM,QAAQ,CAAC;AAC3C,YAAM,OAAO,YAAY,CAAC,GAAG,MAAM,YAAY,GAAG,YAAY;AAC9D,UAAI,QAAQ;AACV,cAAM,OAAO,iBAAiB,CAAC,GAAG,MAAM,iBAAiB,GAAG,OAAO;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAEA,aAAW,YAAY,WAAW;AAChC,UAAM,UAAU,CAAC,CAAC;AAAA,EACpB;AACA,SAAO,EAAE,SAAS,IAAI;AACxB;AAEA,IAAM,QAAQ;AAAA,EACZ,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,UAAU;AACZ;AAEA,SAAS,QACP,MACA,MACS;AACT,SAAO,UAAU,KAAK,OAAO,GAAG,CAAC,CAAC,EAAE;AAAA,IAClC,CAAC,CAAC,IAAI,MAAM,OAAO,SAAS,YAAY,MAAM,IAAI,EAAE,KAAK,IAAI;AAAA,EAC/D;AACF;AAEA,SAAS,WAAW,MAAwC;AAC1D,SAAO,OAAO,KAAK,IAAI,EAAE,KAAK,CAAC,QAAQ,CAAC,IAAI,WAAW,GAAG,CAAC;AAC7D;AASO,SAAS,UAAU,OAAgB,MAA+B;AACvE,MAAI,CAAC,MAAM,QAAQ,KAAK,GAAG;AACzB,WAAO,CAAC,CAAC,OAAO,IAAI,CAAC;AAAA,EACvB;AACA,QAAM,UAA6B,CAAC;AACpC,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,QAAI,OAAO,OAAO,CAAC,GAAG;AACpB,cAAQ,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC;AAAA,IACvC;AAAA,EACF;AACA,SAAO;AACT;AAgDO,SAAS,iBAAiB,MAAyC;AACxE,MAAI,OAAO,SAAS,UAAU;AAC5B,UAAM,IAAI;AAAA,MACR;AAAA,MACA,oFAAoF,cAAc,IAAI,CAAC;AAAA,IACzG;AAAA,EACF;AAEA,QAAM,YAAuB,CAAC;AAU9B,aAAW,CAAC,EAAE,SAAS,aAAa,IAAI,OAAO,EAAE,KAAK,KAAK;AAAA,IACzD;AAAA,EACF,GAAG;AACD,QACE,SAAS,YAAY,MAAM,YAC3B,gDAAgD,KAAK,UAAU,GAC/D;AACA,YAAM,OAAO,KACV,QAAQ,wCAAwC,EAAE,EAClD,QAAQ,iCAAiC,EAAE;AAC9C,YAAM,OACJ,UAAU,IAAI,KACd;AAAA,QACE,KAAK;AAAA,UAAQ;AAAA,UAAkC,CAAC,WAC9C,OAAO,QAAQ,aAAa,CAAC,MAAM,KAAK,UAAU,CAAC,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,QACnE;AAAA,MACF;AAEF,gBAAU,KAAK,IAAI;AAAA,IACrB;AAAA,EACF;AAEA,QAAM,EAAE,SAAS,IAAI,IAAI,OAAO,SAAS;AACzC,SAAO,QAAQ,IAAI,CAAC,EAAE,KAAK,MAAM,WAAW,MAAM,GAAG,CAAC;AACxD;AAQA,SAAS,WACP,MACA,KACyB;AACzB,QAAM,SAAS,CAAC,UAA4B;AAC1C,UAAM,KACJ,SAAS,KAAK,KAAK,OAAO,KAAK,KAAK,EAAE,KAAK,MAAM,SAAS,MAAM,KAAK;AACvE,UAAM,SAAS,OAAO,OAAO,YAAY,IAAI,IAAI,EAAE,GAAG;AACtD,WAAO,UAAU,WAAW,OAAO,SAAS;AAAA,EAC9C;AACA,QAAM,SAAS,CAAC,UACd,MAAM,QAAQ,KAAK,IAAI,MAAM,IAAI,MAAM,IAAI,OAAO,KAAK;AACzD,QAAM,OAAO,CAAC,OAAgB,OAAe,SAA4B;AACvE,QAAI,QAAQ,WAAW;AACrB,aAAO;AAAA,IACT;AACA,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,aAAO,MAAM,IAAI,CAAC,UAAmB,KAAK,OAAO,QAAQ,GAAG,IAAI,CAAC;AAAA,IACnE;AAEA,UAAM,SAAS,SAAS,KAAK;AAC7B,QAAI,CAAC,QAAQ;AACX,aAAO;AAAA,IACT;AAEA,UAAM,SAAS,EAAE,GAAG,OAAO;AAC3B,eAAW,OAAO,MAAM;AACtB,UAAI,OAAO,GAAG,MAAM,QAAW;AAC7B,eAAO,GAAG,IACR,QAAQ,UACJ,OAAO,OAAO,GAAG,CAAC,IAClB,KAAK,OAAO,GAAG,GAAG,QAAQ,GAAG,CAAC,mBAAmB,QAAQ,OAAO,CAAC;AAAA,MACzE;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,SAAO,KAAK,MAAM,GAAG,CAAC,SAAS,oBAAoB,CAAC;AACtD;AAEA,SAAS,UAAU,MAAuB;AACxC,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|
|
@@ -7,10 +7,10 @@ import { parseIngredientLine } from "../ingredient/parse.js";
|
|
|
7
7
|
import { issue, warning } from "../issues.js";
|
|
8
8
|
import { matchAmount } from "../quantity/parse.js";
|
|
9
9
|
import { parseIsoDuration } from "../recipe/duration.js";
|
|
10
|
-
import {
|
|
10
|
+
import { parseRecipeWith } from "../recipe/parse.js";
|
|
11
11
|
import { disallowedScheme } from "../recipe/url.js";
|
|
12
12
|
import { asRecord, toResult } from "../validation.js";
|
|
13
|
-
import { locate, MAX_DEPTH } from "./find.js";
|
|
13
|
+
import { entriesOf, locate, MAX_DEPTH } from "./find.js";
|
|
14
14
|
import { NUTRIENTS } from "./nutrients.js";
|
|
15
15
|
import { cleanLines, cleanText } from "./text.js";
|
|
16
16
|
var READ_FIELDS = [
|
|
@@ -33,6 +33,7 @@ var TIMES = [
|
|
|
33
33
|
["cook", "cookTime"],
|
|
34
34
|
["total", "totalTime"]
|
|
35
35
|
];
|
|
36
|
+
var MAX_URL_LENGTH = 4096;
|
|
36
37
|
var SERVINGS_WORD = /^(?:servings?|portions?|(?:serving|portion)\(s\)|people|persons?)\.?$/i;
|
|
37
38
|
function fromJsonLd(input) {
|
|
38
39
|
const { recipes, ids } = locate([input]);
|
|
@@ -44,7 +45,7 @@ function fromJsonLd(input) {
|
|
|
44
45
|
"error",
|
|
45
46
|
"invalid_type",
|
|
46
47
|
[],
|
|
47
|
-
`Expected a schema.org Recipe, or an array, @graph, or
|
|
48
|
+
`Expected a schema.org Recipe, or an array, @graph, mainEntity, or ItemList that holds one, received ${describeValue(input)}.`
|
|
48
49
|
)
|
|
49
50
|
]),
|
|
50
51
|
extra: {}
|
|
@@ -57,7 +58,9 @@ function fromJsonLd(input) {
|
|
|
57
58
|
warnings: [],
|
|
58
59
|
sources: /* @__PURE__ */ new Map(),
|
|
59
60
|
droppable: /* @__PURE__ */ new Set(),
|
|
60
|
-
images: /* @__PURE__ */ new Map()
|
|
61
|
+
images: [/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map()],
|
|
62
|
+
repeated: 0,
|
|
63
|
+
deepest: 0
|
|
61
64
|
};
|
|
62
65
|
const name = text(node.name);
|
|
63
66
|
if (recipes.length > 1) {
|
|
@@ -69,14 +72,15 @@ function fromJsonLd(input) {
|
|
|
69
72
|
);
|
|
70
73
|
}
|
|
71
74
|
const model = readRecipe(reader, node, found.path);
|
|
72
|
-
const parse = () =>
|
|
75
|
+
const parse = () => parseRecipeWith(model, parseIngredientLine);
|
|
73
76
|
let result = parse();
|
|
74
77
|
const dropped = [];
|
|
75
78
|
if (!result.success) {
|
|
79
|
+
const targets = [];
|
|
76
80
|
for (const error of result.error.issues) {
|
|
77
81
|
const target = error.severity === "error" ? droppableAt(reader, error.path) : void 0;
|
|
78
82
|
if (target) {
|
|
79
|
-
|
|
83
|
+
targets.push(target);
|
|
80
84
|
dropped.push({
|
|
81
85
|
...error,
|
|
82
86
|
severity: "warning",
|
|
@@ -84,12 +88,13 @@ function fromJsonLd(input) {
|
|
|
84
88
|
});
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
targets.sort((a, b) => b.length - a.length).forEach((target) => remove(model, target));
|
|
87
92
|
if (dropped.length > 0) {
|
|
88
93
|
result = parse();
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
const order = ({ code, path }) => [
|
|
92
|
-
READ_FIELDS.indexOf(String(path[found.path.length])),
|
|
97
|
+
path.length > found.path.length ? READ_FIELDS.indexOf(String(path[found.path.length])) : -1,
|
|
93
98
|
...path.slice(found.path.length + 1).filter((key) => typeof key === "number"),
|
|
94
99
|
...code === "unknown_field" ? [Infinity] : []
|
|
95
100
|
];
|
|
@@ -100,6 +105,25 @@ function fromJsonLd(input) {
|
|
|
100
105
|
...result.success ? result.warnings : result.error.issues
|
|
101
106
|
].map((issue2) => ({ ...issue2, path: sourceOf(reader, issue2.path) }))
|
|
102
107
|
].sort((a, b) => compareOrder(order(a), order(b)));
|
|
108
|
+
const checked = /* @__PURE__ */ new Map();
|
|
109
|
+
const extra = Object.fromEntries(
|
|
110
|
+
Object.entries(node).filter(([key, value]) => {
|
|
111
|
+
if (READ_FIELDS.includes(key)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const path = [...found.path, key];
|
|
115
|
+
if (tooDeep(value, path.length, checked)) {
|
|
116
|
+
warning(
|
|
117
|
+
issues,
|
|
118
|
+
"invalid_type",
|
|
119
|
+
path,
|
|
120
|
+
`Expected a value to keep in extra, received ${describeValue(value)} nested more than ${MAX_DEPTH} levels deep. It is ignored.`
|
|
121
|
+
);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
})
|
|
126
|
+
);
|
|
103
127
|
return {
|
|
104
128
|
...toResult(
|
|
105
129
|
"invalid_recipe",
|
|
@@ -107,11 +131,24 @@ function fromJsonLd(input) {
|
|
|
107
131
|
issues,
|
|
108
132
|
typeof name === "string" ? quote(name) : void 0
|
|
109
133
|
),
|
|
110
|
-
extra
|
|
111
|
-
Object.entries(node).filter(([key]) => !READ_FIELDS.includes(key))
|
|
112
|
-
)
|
|
134
|
+
extra
|
|
113
135
|
};
|
|
114
136
|
}
|
|
137
|
+
function tooDeep(value, length, checked) {
|
|
138
|
+
if (length > MAX_DEPTH) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value !== "object" || value === null || (checked.get(value) ?? 0) >= length) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const deep = Object.values(value).some(
|
|
145
|
+
(child) => tooDeep(child, length + 1, checked)
|
|
146
|
+
);
|
|
147
|
+
if (!deep) {
|
|
148
|
+
checked.set(value, length);
|
|
149
|
+
}
|
|
150
|
+
return deep;
|
|
151
|
+
}
|
|
115
152
|
function readRecipe(reader, node, base) {
|
|
116
153
|
const at = (...path) => [...base, ...path];
|
|
117
154
|
const model = {
|
|
@@ -159,10 +196,9 @@ function readImage(reader, value, source) {
|
|
|
159
196
|
if (isAbsent(value)) {
|
|
160
197
|
return void 0;
|
|
161
198
|
}
|
|
162
|
-
const warnings = reader.warnings.length;
|
|
163
|
-
reader.repeated = false;
|
|
199
|
+
const warnings = reader.warnings.length + reader.repeated;
|
|
164
200
|
const image = findImage(reader, value, source);
|
|
165
|
-
if (!image &&
|
|
201
|
+
if (!image && reader.warnings.length + reader.repeated === warnings) {
|
|
166
202
|
ignore(
|
|
167
203
|
reader,
|
|
168
204
|
"invalid_type",
|
|
@@ -173,6 +209,18 @@ function readImage(reader, value, source) {
|
|
|
173
209
|
return image;
|
|
174
210
|
}
|
|
175
211
|
function findImage(reader, value, source, referenced = false) {
|
|
212
|
+
reader.deepest = Math.max(reader.deepest, source.length);
|
|
213
|
+
if (source.length > MAX_DEPTH) {
|
|
214
|
+
if (!isAbsent(value)) {
|
|
215
|
+
ignore(
|
|
216
|
+
reader,
|
|
217
|
+
"invalid_type",
|
|
218
|
+
source,
|
|
219
|
+
`Expected an image URL, an ImageObject with a url, or a list of them, received ${describeValue(value)} nested more than ${MAX_DEPTH} levels deep.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return void 0;
|
|
223
|
+
}
|
|
176
224
|
if (typeof value === "string") {
|
|
177
225
|
const url = cleanText(value);
|
|
178
226
|
const scheme = disallowedScheme(url);
|
|
@@ -185,60 +233,75 @@ function findImage(reader, value, source, referenced = false) {
|
|
|
185
233
|
);
|
|
186
234
|
return void 0;
|
|
187
235
|
}
|
|
236
|
+
if (url.length > MAX_URL_LENGTH) {
|
|
237
|
+
ignore(
|
|
238
|
+
reader,
|
|
239
|
+
"invalid_url",
|
|
240
|
+
source,
|
|
241
|
+
`Expected a URL of at most ${MAX_URL_LENGTH} UTF-16 code units, received ${url.length}.`
|
|
242
|
+
);
|
|
243
|
+
return void 0;
|
|
244
|
+
}
|
|
188
245
|
return url ? { url, source } : void 0;
|
|
189
246
|
}
|
|
190
|
-
if (source.length > MAX_DEPTH) {
|
|
191
|
-
return void 0;
|
|
192
|
-
}
|
|
193
247
|
if (Array.isArray(value)) {
|
|
194
|
-
for (const [
|
|
195
|
-
const image2 = findImage(reader, entry,
|
|
248
|
+
for (const [entry, entrySource] of entriesOf(value, source)) {
|
|
249
|
+
const image2 = findImage(reader, entry, entrySource, referenced);
|
|
196
250
|
if (image2) {
|
|
197
251
|
return image2;
|
|
198
252
|
}
|
|
199
253
|
}
|
|
200
254
|
return void 0;
|
|
201
255
|
}
|
|
256
|
+
const images = reader.images[referenced ? 1 : 0];
|
|
257
|
+
const reads = images.get(value) ?? [];
|
|
258
|
+
const read = reads.find(
|
|
259
|
+
([, , depth, length]) => length === source.length || Math.max(length, source.length) + depth <= MAX_DEPTH
|
|
260
|
+
);
|
|
261
|
+
if (read) {
|
|
262
|
+
reader.repeated += read[1];
|
|
263
|
+
reader.deepest = Math.max(reader.deepest, source.length + read[2]);
|
|
264
|
+
return read[0];
|
|
265
|
+
}
|
|
202
266
|
const record = asRecord(value);
|
|
203
267
|
if (!record) {
|
|
204
268
|
return void 0;
|
|
205
269
|
}
|
|
270
|
+
images.set(value, reads);
|
|
271
|
+
const warnings = reader.warnings.length + reader.repeated;
|
|
272
|
+
const deepest = reader.deepest;
|
|
273
|
+
reader.deepest = source.length;
|
|
206
274
|
const id = record["@id"];
|
|
207
275
|
const target = typeof id === "string" && !referenced ? reader.ids.get(id) : void 0;
|
|
208
|
-
|
|
209
|
-
if (image
|
|
210
|
-
return image;
|
|
211
|
-
}
|
|
212
|
-
if (target.node === reader.recipe) {
|
|
276
|
+
let image = findImage(reader, record.url, [...source, "url"], referenced) ?? findImage(reader, record.contentUrl, [...source, "contentUrl"], referenced);
|
|
277
|
+
if (!image && target?.node === reader.recipe) {
|
|
213
278
|
ignore(
|
|
214
279
|
reader,
|
|
215
280
|
"invalid_type",
|
|
216
281
|
source,
|
|
217
282
|
"Expected an image URL, an ImageObject with a url, or a list of them, received a reference to the recipe itself."
|
|
218
283
|
);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return read[0];
|
|
284
|
+
} else if (!image && target) {
|
|
285
|
+
image = findImage(reader, target.node, target.path, true);
|
|
286
|
+
}
|
|
287
|
+
reads.push([
|
|
288
|
+
image,
|
|
289
|
+
reader.warnings.length + reader.repeated - warnings,
|
|
290
|
+
reader.deepest - source.length,
|
|
291
|
+
source.length
|
|
292
|
+
]);
|
|
293
|
+
reader.deepest = Math.max(reader.deepest, deepest);
|
|
294
|
+
return image;
|
|
231
295
|
}
|
|
232
296
|
function readYield(reader, model, value, source) {
|
|
233
|
-
const
|
|
234
|
-
const batches = entries.flatMap(
|
|
297
|
+
const batches = entriesOf(value, source).flatMap(
|
|
235
298
|
([entry, entrySource]) => (typeof entry === "string" ? entry.split(/,\s+/) : [entry]).map(
|
|
236
299
|
(part) => readBatch(reader, part, entrySource)
|
|
237
300
|
)
|
|
238
301
|
).filter((batch) => batch !== void 0);
|
|
239
302
|
const yields = batches.filter((batch) => batch.yield !== void 0);
|
|
240
303
|
const servings = batches.filter((batch) => batch.servings !== void 0);
|
|
241
|
-
const kept = yields.find((batch) => !batch.article) ?? yields
|
|
304
|
+
const kept = yields.find((batch) => !batch.article) ?? yields.find((batch) => batch.article);
|
|
242
305
|
const keptServings = servings.find((batch) => batch.worded) ?? servings.find((batch) => batch.amount !== kept?.amount);
|
|
243
306
|
for (const batch of servings) {
|
|
244
307
|
if (keptServings && batch.amount !== keptServings.amount && (batch.worded || batch.amount !== kept?.amount)) {
|
|
@@ -305,7 +368,7 @@ function readBatch(reader, value, source) {
|
|
|
305
368
|
amount,
|
|
306
369
|
...max !== void 0 && max !== amount && { max }
|
|
307
370
|
};
|
|
308
|
-
const label = `${amount}${
|
|
371
|
+
const label = `${amount}${max !== amount && max ? `\u2013${max}` : ""}${unit && ` ${unit}`}`;
|
|
309
372
|
return !unit || SERVINGS_WORD.test(unit) ? { source, label, amount, servings: range, worded: !!unit } : { source, label, amount, yield: { ...range, unit } };
|
|
310
373
|
}
|
|
311
374
|
if (!isAbsent(value)) {
|
|
@@ -351,15 +414,17 @@ function readList(reader, key, value, source, readItem, expected) {
|
|
|
351
414
|
return;
|
|
352
415
|
}
|
|
353
416
|
if (Array.isArray(entry)) {
|
|
354
|
-
const
|
|
355
|
-
|
|
417
|
+
const elements2 = entriesOf(entry, entrySource);
|
|
418
|
+
const positions = elements2.map(
|
|
419
|
+
([element]) => toNumber(asRecord(element)?.position)
|
|
356
420
|
);
|
|
357
|
-
const order = [...
|
|
421
|
+
const order = [...elements2.keys()];
|
|
358
422
|
if (!positions.includes(void 0)) {
|
|
359
423
|
order.sort((a, b) => positions[a] - positions[b]);
|
|
360
424
|
}
|
|
361
425
|
for (const i of order) {
|
|
362
|
-
|
|
426
|
+
const [element, elementSource] = elements2[i];
|
|
427
|
+
add(items, path, element, elementSource, inSection);
|
|
363
428
|
}
|
|
364
429
|
return;
|
|
365
430
|
}
|
|
@@ -385,6 +450,10 @@ function readList(reader, key, value, source, readItem, expected) {
|
|
|
385
450
|
}
|
|
386
451
|
return;
|
|
387
452
|
}
|
|
453
|
+
if (record && !isAbsent(record.item)) {
|
|
454
|
+
add(items, path, record.item, [...entrySource, "item"], inSection);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
388
457
|
const read = readItem(reader, entry, entrySource);
|
|
389
458
|
if (!read) {
|
|
390
459
|
ignore(
|
|
@@ -399,7 +468,7 @@ function readList(reader, key, value, source, readItem, expected) {
|
|
|
399
468
|
const itemPath = [...path, items.length];
|
|
400
469
|
items.push(item.value);
|
|
401
470
|
link(reader, itemPath, item.source, true);
|
|
402
|
-
for (const [field, fieldSource, droppable] of item.fields
|
|
471
|
+
for (const [field, fieldSource, droppable] of item.fields) {
|
|
403
472
|
link(reader, [...itemPath, field], fieldSource, droppable);
|
|
404
473
|
}
|
|
405
474
|
}
|
|
@@ -409,26 +478,28 @@ function readList(reader, key, value, source, readItem, expected) {
|
|
|
409
478
|
}
|
|
410
479
|
function readLine(reader, value, source) {
|
|
411
480
|
if (typeof value === "string") {
|
|
412
|
-
return cleanLines(value).map((line) => ({
|
|
481
|
+
return cleanLines(value).map((line) => ({
|
|
482
|
+
value: line,
|
|
483
|
+
source,
|
|
484
|
+
fields: []
|
|
485
|
+
}));
|
|
413
486
|
}
|
|
414
487
|
const record = asRecord(value);
|
|
415
|
-
if (record?.item !== void 0 && source.length < MAX_DEPTH) {
|
|
416
|
-
return readLine(reader, record.item, [...source, "item"]);
|
|
417
|
-
}
|
|
418
488
|
const parts = [record?.value, record?.unitText, record?.name ?? record?.text].filter((part) => typeof part === "string" || typeof part === "number").join(" ");
|
|
419
|
-
return parts ? cleanLines(parts).map((line) => ({ value: line, source })) : void 0;
|
|
489
|
+
return parts ? cleanLines(parts).map((line) => ({ value: line, source, fields: [] })) : void 0;
|
|
420
490
|
}
|
|
421
491
|
function readStep(reader, value, source) {
|
|
422
492
|
if (typeof value === "string") {
|
|
423
|
-
return cleanLines(value).map((line) => ({
|
|
493
|
+
return cleanLines(value).map((line) => ({
|
|
494
|
+
value: line,
|
|
495
|
+
source,
|
|
496
|
+
fields: []
|
|
497
|
+
}));
|
|
424
498
|
}
|
|
425
499
|
const record = asRecord(value);
|
|
426
500
|
if (!record) {
|
|
427
501
|
return void 0;
|
|
428
502
|
}
|
|
429
|
-
if (record.item !== void 0 && source.length < MAX_DEPTH) {
|
|
430
|
-
return readStep(reader, record.item, [...source, "item"]);
|
|
431
|
-
}
|
|
432
503
|
const name = typeof record.name === "string" ? cleanText(record.name) : "";
|
|
433
504
|
const body = text(record.text) ?? name;
|
|
434
505
|
const image = readImage(reader, record.image, [...source, "image"]);
|
|
@@ -453,10 +524,9 @@ function readStep(reader, value, source) {
|
|
|
453
524
|
];
|
|
454
525
|
}
|
|
455
526
|
function readTools(reader, value, source) {
|
|
456
|
-
const entries = Array.isArray(value) ? value.map((entry, i) => [entry, [...source, i]]) : [[value, source]];
|
|
457
527
|
const equipment = [];
|
|
458
528
|
link(reader, ["equipment"], source);
|
|
459
|
-
for (const [entry, entrySource] of
|
|
529
|
+
for (const [entry, entrySource] of entriesOf(value, source)) {
|
|
460
530
|
const record = asRecord(entry);
|
|
461
531
|
const item = record ? {
|
|
462
532
|
name: text(record.name),
|
|
@@ -543,10 +613,10 @@ function sourceOf(reader, path) {
|
|
|
543
613
|
}
|
|
544
614
|
function compareOrder(a, b) {
|
|
545
615
|
for (const [i, n] of a.entries()) {
|
|
546
|
-
|
|
547
|
-
if (m === void 0) {
|
|
616
|
+
if (i === b.length) {
|
|
548
617
|
return 1;
|
|
549
618
|
}
|
|
619
|
+
const m = b[i];
|
|
550
620
|
if (n !== m) {
|
|
551
621
|
return n - m;
|
|
552
622
|
}
|