@recipe-book/core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-VWVIBCTG.cjs → chunk-D5CV3UF7.cjs} +188 -173
  4. package/dist/chunk-D5CV3UF7.cjs.map +1 -0
  5. package/dist/errors.js.map +1 -1
  6. package/dist/format/number.js +1 -1
  7. package/dist/format/number.js.map +1 -1
  8. package/dist/format/options.js.map +1 -1
  9. package/dist/index.cjs +83 -74
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +35 -19
  12. package/dist/index.d.ts +35 -19
  13. package/dist/ingredient/parse.js +27 -22
  14. package/dist/ingredient/parse.js.map +1 -1
  15. package/dist/issues.js.map +1 -1
  16. package/dist/quantity/amount.js +11 -0
  17. package/dist/quantity/amount.js.map +1 -1
  18. package/dist/quantity/parse.js +58 -81
  19. package/dist/quantity/parse.js.map +1 -1
  20. package/dist/quantity/scale.js +5 -2
  21. package/dist/quantity/scale.js.map +1 -1
  22. package/dist/recipe/parse.js +70 -55
  23. package/dist/recipe/parse.js.map +1 -1
  24. package/dist/recipe/schema.js +8 -6
  25. package/dist/recipe/schema.js.map +1 -1
  26. package/dist/resolve.js +6 -1
  27. package/dist/resolve.js.map +1 -1
  28. package/dist/schema-org/find.js +46 -13
  29. package/dist/schema-org/find.js.map +1 -1
  30. package/dist/schema-org/from-json-ld.js +127 -57
  31. package/dist/schema-org/from-json-ld.js.map +1 -1
  32. package/dist/schema-org/text.js.map +1 -1
  33. package/dist/schema-org/to-json-ld.js +12 -15
  34. package/dist/schema-org/to-json-ld.js.map +1 -1
  35. package/dist/schema-org.cjs +231 -132
  36. package/dist/schema-org.cjs.map +1 -1
  37. package/dist/schema-org.d.cts +32 -19
  38. package/dist/schema-org.d.ts +32 -19
  39. package/dist/steps/temperature.js +13 -13
  40. package/dist/steps/temperature.js.map +1 -1
  41. package/dist/{types-DSMf45H5.d.cts → types-BknM8KOd.d.cts} +4 -3
  42. package/dist/{types-DSMf45H5.d.ts → types-BknM8KOd.d.ts} +4 -3
  43. package/dist/units/convert.js +16 -8
  44. package/dist/units/convert.js.map +1 -1
  45. package/dist/units/temperature.js +7 -1
  46. package/dist/units/temperature.js.map +1 -1
  47. package/dist/validation.js +1 -1
  48. package/dist/validation.js.map +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-VWVIBCTG.cjs.map +0 -1
@@ -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, mainEntity = false) => {
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.forEach(
16
- (entry, i) => visit(entry, [...path, i], mainEntity)
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 type = fields["@type"];
25
- const isRecipe = [type].flat().some(
26
- (name) => typeof name === "string" && /(?:^|[/:#])Recipe$/.test(name)
27
- );
28
- if (mainEntity && !isRecipe) {
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"], true);
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[0] !== "@");
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).length === 1 && value["@id"];
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":"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","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 { parseRecipe } from "../recipe/parse.js";
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 mainEntity that holds one, received ${describeValue(input)}.`
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 = () => parseRecipe(model, { parseIngredientLine });
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
- remove(model, target);
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: Object.fromEntries(
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 && !reader.repeated && reader.warnings.length === warnings) {
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 [i, entry] of value.entries()) {
195
- const image2 = findImage(reader, entry, [...source, i], referenced);
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
- const image = findImage(reader, record.url, [...source, "url"], referenced) ?? findImage(reader, record.contentUrl, [...source, "contentUrl"], referenced);
209
- if (image || !target) {
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
- return void 0;
220
- }
221
- let read = reader.images.get(target);
222
- if (!read) {
223
- const warnings = reader.warnings.length;
224
- const found = findImage(reader, target.node, target.path, true);
225
- read = [found, reader.warnings.length > warnings];
226
- reader.images.set(target, read);
227
- } else if (read[1]) {
228
- reader.repeated = true;
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 entries = Array.isArray(value) ? value.map((entry, i) => [entry, [...source, i]]) : [[value, source]];
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[0];
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}${range.max ? `\u2013${range.max}` : ""}${unit && ` ${unit}`}`;
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 positions = entry.map(
355
- (element) => toNumber(asRecord(element)?.position)
417
+ const elements2 = entriesOf(entry, entrySource);
418
+ const positions = elements2.map(
419
+ ([element]) => toNumber(asRecord(element)?.position)
356
420
  );
357
- const order = [...entry.keys()];
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
- add(items, path, entry[i], [...entrySource, i], inSection);
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) => ({ value: line, source }));
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) => ({ value: line, source }));
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 entries) {
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
- const m = b[i];
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
  }