@recipe-book/core 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-VWVIBCTG.cjs → chunk-ISRDJR2O.cjs} +227 -179
  4. package/dist/chunk-ISRDJR2O.cjs.map +1 -0
  5. package/dist/errors.js +29 -1
  6. package/dist/errors.js.map +1 -1
  7. package/dist/format/duration.js.map +1 -1
  8. package/dist/format/ingredient.js.map +1 -1
  9. package/dist/format/number.js +1 -1
  10. package/dist/format/number.js.map +1 -1
  11. package/dist/format/options.js.map +1 -1
  12. package/dist/format/quantity.js.map +1 -1
  13. package/dist/format/servings.js.map +1 -1
  14. package/dist/format/temperature.js.map +1 -1
  15. package/dist/index.cjs +87 -74
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +71 -28
  18. package/dist/index.d.ts +71 -28
  19. package/dist/index.js +6 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/ingredient/parse.js +27 -22
  22. package/dist/ingredient/parse.js.map +1 -1
  23. package/dist/issues.js.map +1 -1
  24. package/dist/quantity/amount.js +11 -0
  25. package/dist/quantity/amount.js.map +1 -1
  26. package/dist/quantity/parse.js +58 -81
  27. package/dist/quantity/parse.js.map +1 -1
  28. package/dist/quantity/scale.js +5 -2
  29. package/dist/quantity/scale.js.map +1 -1
  30. package/dist/recipe/ids.js.map +1 -1
  31. package/dist/recipe/parse.js +80 -60
  32. package/dist/recipe/parse.js.map +1 -1
  33. package/dist/recipe/schema.js +8 -6
  34. package/dist/recipe/schema.js.map +1 -1
  35. package/dist/resolve.js +6 -1
  36. package/dist/resolve.js.map +1 -1
  37. package/dist/schema-org/find.js +46 -13
  38. package/dist/schema-org/find.js.map +1 -1
  39. package/dist/schema-org/from-json-ld.js +134 -71
  40. package/dist/schema-org/from-json-ld.js.map +1 -1
  41. package/dist/schema-org/text.js.map +1 -1
  42. package/dist/schema-org/to-json-ld.js +12 -15
  43. package/dist/schema-org/to-json-ld.js.map +1 -1
  44. package/dist/schema-org.cjs +239 -147
  45. package/dist/schema-org.cjs.map +1 -1
  46. package/dist/schema-org.d.cts +35 -21
  47. package/dist/schema-org.d.ts +35 -21
  48. package/dist/steps/segment.js.map +1 -1
  49. package/dist/steps/temperature.js +13 -13
  50. package/dist/steps/temperature.js.map +1 -1
  51. package/dist/{types-DSMf45H5.d.cts → types-CKIoWsz8.d.cts} +25 -7
  52. package/dist/{types-DSMf45H5.d.ts → types-CKIoWsz8.d.ts} +25 -7
  53. package/dist/units/convert.js +16 -8
  54. package/dist/units/convert.js.map +1 -1
  55. package/dist/units/temperature.js +7 -1
  56. package/dist/units/temperature.js.map +1 -1
  57. package/dist/validation.js +1 -1
  58. package/dist/validation.js.map +1 -1
  59. package/package.json +1 -1
  60. package/dist/chunk-VWVIBCTG.cjs.map +0 -1
@@ -12,7 +12,7 @@ import { NUTRIENTS } from "./nutrients.js";
12
12
  function toJsonLd(recipe, options) {
13
13
  const { servings, system, url, extra } = options ?? {};
14
14
  const resolved = resolveRecipe(recipe, { servings, system });
15
- const page = typeof url === "string" ? /^(?:[\s\S]*[\n\r\u2028\u2029])?[^#\n\r\u2028\u2029]*/.exec(url)[0] : void 0;
15
+ const page = typeof url === "string" ? url.replace(/#.*/s, "") : void 0;
16
16
  const yields = [
17
17
  ...resolved.servings ? servingsText(resolved.servings) : [],
18
18
  ...resolved.yield ? [formatYield(resolved.yield, { fractions: false })] : []
@@ -22,7 +22,17 @@ function toJsonLd(recipe, options) {
22
22
  );
23
23
  const steps = resolved.steps.flatMap(
24
24
  ({ title, items }) => {
25
- const howToSteps = items.map((step) => howToStep(step, page));
25
+ const howToSteps = items.map((step) => ({
26
+ "@type": "HowToStep",
27
+ ...step.name !== void 0 && { name: step.name },
28
+ text: step.text,
29
+ ...step.image !== void 0 && { image: step.image },
30
+ ...page !== void 0 && {
31
+ // encodeURIComponent throws on a lone surrogate. With the u flag,
32
+ // \p{Cs} matches only those, as a pair reads as one code point.
33
+ url: `${page}#${encodeURIComponent(step.id.replace(/\p{Cs}/gu, "\uFFFD"))}`
34
+ }
35
+ }));
26
36
  return title === void 0 || howToSteps.length === 0 ? howToSteps : [
27
37
  {
28
38
  "@type": "HowToSection",
@@ -94,19 +104,6 @@ function ingredientLine(item, scale, system) {
94
104
  `${head}${note ? `, ${note}` : ""}${ingredient.optional ? " (optional)" : ""}`
95
105
  );
96
106
  }
97
- function howToStep(step, page) {
98
- return {
99
- "@type": "HowToStep",
100
- ...step.name !== void 0 && { name: step.name },
101
- text: step.text,
102
- ...step.image !== void 0 && { image: step.image },
103
- ...page !== void 0 && {
104
- // encodeURIComponent throws on a lone surrogate. With the u flag,
105
- // \p{Cs} matches only those, as a pair reads as one code point.
106
- url: `${page}#${encodeURIComponent(step.id.replace(/\p{Cs}/gu, "\uFFFD"))}`
107
- }
108
- };
109
- }
110
107
  function duration(warnings, key, minutes) {
111
108
  if (minutes === void 0) {
112
109
  return {};
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/schema-org/to-json-ld.ts"],"sourcesContent":["import { describeValue, type RecipeIssue } from '../errors'\nimport { formatIngredient } from '../format/ingredient'\nimport { round } from '../format/number'\nimport { formatQuantity } from '../format/quantity'\nimport { formatServings, formatYield } from '../format/servings'\nimport { hasOwn, isRecord, warning } from '../issues'\nimport { multiply } from '../quantity/scale'\nimport { NUTRIENT_UNITS, type NutrientKey } from '../recipe/nutrient-units'\nimport type { Nutrition, Recipe, Step } from '../recipe/types'\nimport { isNative, type ResolvedIngredient, resolveRecipe } from '../resolve'\nimport type { System } from '../units/registry'\nimport { NUTRIENTS } from './nutrients'\nimport type {\n SchemaOrgHowToSection,\n SchemaOrgHowToStep,\n SchemaOrgNutritionInformation,\n SchemaOrgRecipe,\n ToJsonLdOptions,\n ToJsonLdResult,\n} from './types'\n\n/**\n * Writes a recipe as schema.org Recipe JSON-LD, for search engines' recipe\n * results. Writes it at its own servings and in `recipe.system`, else metric,\n * unless the options say otherwise; invalid options fall back to these, as in\n * `resolveRecipe`. Never throws for an unchanged recipe from `parseRecipe` or\n * `fromJsonLd`.\n *\n * `data` is plain JSON when `extra` is. Write it into a\n * `<script type=\"application/ld+json\">` tag with `<` escaped as `\\u003c`, so\n * recipe text can't end the tag. An `image` passed in `extra` is a URL; for an\n * `ImageObject` or several images, spread `data` into a new object with your\n * own `image`.\n *\n * Ingredient lines read back with `parseIngredientLine`: a quantity's\n * alternatives follow it in parentheses, as in `\"1 ½ cups (190 g) flour,\n * sifted\"` and `\"2 eggs (100 g)\"`, and an optional ingredient ends\n * `\" (optional)\"`. schema.org has no place for ingredient section titles, the\n * recipe's notes, custom times, an ingredient's `url`, or equipment beyond its\n * name, so they aren't written. Reading `data` back with `fromJsonLd` gives a\n * similar recipe, not the same one.\n *\n * `warnings` names the fields Google requires for recipe results that `data`\n * lacks, with paths that use the schema.org names: `image`, and `recipeYield`\n * when there is nutrition. A time longer than `Number.MAX_SAFE_INTEGER`\n * minutes is left out, as `fromJsonLd` leaves it out, and so is a `NaN` time,\n * each with an `invalid_duration` warning at its path in the recipe, such as\n * `['times', 'cook']`.\n *\n * @example\n * const { data } = toJsonLd(recipe, { url: 'https://example.com/pancakes' })\n * data.recipeYield // ['4', '4 servings']\n * data.recipeIngredient // ['1 ½ cups (190 g) flour, sifted', '2 eggs', 'salt, to taste']\n */\nexport function toJsonLd(\n recipe: Recipe,\n options?: ToJsonLdOptions,\n): ToJsonLdResult {\n const { servings, system, url, extra } = options ?? {}\n const resolved = resolveRecipe(recipe, { servings, system })\n // Up to the first `#` after the last line break. Anchored at the start, so\n // a long run of `#` before a line break isn't rescanned from each one.\n const page =\n typeof url === 'string'\n ? /^(?:[\\s\\S]*[\\n\\r\\u2028\\u2029])?[^#\\n\\r\\u2028\\u2029]*/.exec(url)![0]\n : undefined\n const yields = [\n ...(resolved.servings ? servingsText(resolved.servings) : []),\n ...(resolved.yield\n ? [formatYield(resolved.yield, { fractions: false })]\n : []),\n ]\n const ingredients = resolved.ingredients.flatMap(({ items }) =>\n items.map((item) => ingredientLine(item, resolved.scale, resolved.system)),\n )\n const steps = resolved.steps.flatMap(\n ({ title, items }): (SchemaOrgHowToStep | SchemaOrgHowToSection)[] => {\n const howToSteps = items.map((step) => howToStep(step, page))\n return title === undefined || howToSteps.length === 0\n ? howToSteps\n : [\n {\n '@type': 'HowToSection',\n name: title,\n itemListElement: howToSteps,\n },\n ]\n },\n )\n const { description, image, times, equipment, nutrition } = recipe\n const warnings: RecipeIssue[] = []\n\n const written: SchemaOrgRecipe = {\n '@context': 'https://schema.org',\n '@type': 'Recipe',\n name: recipe.name,\n ...(description !== undefined && { description }),\n ...(image !== undefined && { image }),\n ...(yields.length > 0 && { recipeYield: yields.map(plainSpaces) }),\n ...duration(warnings, 'prep', times.prep),\n ...duration(warnings, 'cook', times.cook),\n ...duration(warnings, 'total', times.total),\n ...(ingredients.length > 0 && { recipeIngredient: ingredients }),\n ...(steps.length > 0 && { recipeInstructions: steps }),\n ...(equipment.length > 0 && { tool: equipment.map(({ name }) => name) }),\n ...(nutrition !== undefined && {\n nutrition: nutritionInformation(nutrition),\n }),\n }\n const data: SchemaOrgRecipe = {\n ...written,\n ...Object.fromEntries(\n Object.entries(isRecord(extra) ? extra : {}).filter(\n ([field, value]) => value !== undefined && !hasOwn(written, field),\n ),\n ),\n }\n\n if (data.image === undefined) {\n warning(\n warnings,\n 'missing_field',\n ['image'],\n 'Add an image. Google requires one for recipe results.',\n )\n }\n if (data.nutrition !== undefined && data.recipeYield === undefined) {\n warning(\n warnings,\n 'missing_field',\n ['recipeYield'],\n 'Add servings or a yield. Google requires one with nutrition, which is for one serving.',\n )\n }\n\n return { data, warnings }\n}\n\n/** `[\"4\", \"4 servings\"]`, in decimals, so the text reads back as a number. */\nfunction servingsText(servings: {\n amount: number\n max?: number | undefined\n}): string[] {\n const amount = formatServings(servings, { fractions: false })\n const word =\n servings.amount === 1 && servings.max === undefined ? 'serving' : 'servings'\n return [amount, `${amount} ${word}`]\n}\n\n/**\n * The line as `formatIngredient` writes it, with the quantity's alternatives\n * in parentheses after it, or after the name for a bare count, and\n * \" (optional)\" at the end.\n */\nfunction ingredientLine(\n item: ResolvedIngredient,\n scale: number,\n system: System,\n): string {\n const { ingredient, origin } = item\n const { quantity, name, note } = formatIngredient(item)\n const authored = ingredient.quantity\n ? [ingredient.quantity, ...ingredient.equivalents]\n : []\n const shown =\n origin === 'authored'\n ? 0\n : origin === 'equivalent'\n ? 1 + ingredient.equivalents.findIndex((q) => isNative(q, system))\n : -1\n const alternatives = authored\n .filter((_, i) => i !== shown)\n .map((q) => formatQuantity(scale === 1 ? q : multiply(q, scale)))\n .join('; ')\n const inParentheses = alternatives && ` (${alternatives})`\n const head =\n quantity === undefined\n ? name\n : item.quantity?.unit === undefined\n ? `${quantity} ${name}${inParentheses}`\n : `${quantity}${inParentheses} ${name}`\n\n return plainSpaces(\n `${head}${note ? `, ${note}` : ''}${ingredient.optional ? ' (optional)' : ''}`,\n )\n}\n\nfunction howToStep(step: Step, page: string | undefined): SchemaOrgHowToStep {\n return {\n '@type': 'HowToStep',\n ...(step.name !== undefined && { name: step.name }),\n text: step.text,\n ...(step.image !== undefined && { image: step.image }),\n ...(page !== undefined && {\n // encodeURIComponent throws on a lone surrogate. With the u flag,\n // \\p{Cs} matches only those, as a pair reads as one code point.\n url: `${page}#${encodeURIComponent(step.id.replace(/\\p{Cs}/gu, '\\uFFFD'))}`,\n }),\n }\n}\n\n/**\n * An ISO 8601 duration to the second, or nothing for an absent or zero time,\n * or one it can't write: `NaN`, which `parseRecipe` rejects but a recipe built\n * by hand can hold, or a time longer than `Number.MAX_SAFE_INTEGER` minutes.\n * Below that bound, the hours fit in a safe integer, so they print in plain\n * digits.\n */\nfunction duration(\n warnings: RecipeIssue[],\n key: 'prep' | 'cook' | 'total',\n minutes: number | undefined,\n): Partial<SchemaOrgRecipe> {\n if (minutes === undefined) {\n return {}\n }\n if (Number.isNaN(minutes) || minutes > Number.MAX_SAFE_INTEGER) {\n warning(\n warnings,\n 'invalid_duration',\n ['times', key],\n `Expected minutes such as 30, received ${describeValue(minutes)}${Number.isNaN(minutes) ? '' : ', which is too long'}. It is ignored.`,\n )\n return {}\n }\n\n const seconds = Math.round(minutes * 60)\n\n if (seconds <= 0) {\n return {}\n }\n\n const parts = [\n [Math.floor(seconds / 3600), 'H'],\n [Math.floor(seconds / 60) % 60, 'M'],\n [seconds % 60, 'S'],\n ] as const\n\n return {\n [`${key}Time`]: `PT${parts.map(([n, unit]) => (n ? `${n}${unit}` : '')).join('')}`,\n }\n}\n\nfunction nutritionInformation(\n nutrition: Nutrition,\n): SchemaOrgNutritionInformation {\n const information: SchemaOrgNutritionInformation = {\n '@type': 'NutritionInformation',\n }\n\n for (const key of Object.keys(NUTRIENTS) as (keyof Nutrition)[]) {\n const value = nutrition[key]\n if (value !== undefined) {\n information[NUTRIENTS[key]] =\n typeof value === 'string'\n ? value\n : `${round(value, 3)} ${NUTRIENT_UNITS[key as NutrientKey]}`\n }\n }\n\n return information\n}\n\nfunction plainSpaces(text: string): string {\n return text.replace(/\\u00A0/g, ' ')\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/schema-org/to-json-ld.ts"],"sourcesContent":["import { describeValue, type RecipeIssue } from '../errors'\nimport { formatIngredient } from '../format/ingredient'\nimport { round } from '../format/number'\nimport { formatQuantity } from '../format/quantity'\nimport { formatServings, formatYield } from '../format/servings'\nimport { hasOwn, isRecord, warning } from '../issues'\nimport { multiply } from '../quantity/scale'\nimport { NUTRIENT_UNITS, type NutrientKey } from '../recipe/nutrient-units'\nimport type { Nutrition, Recipe } from '../recipe/types'\nimport { isNative, type ResolvedIngredient, resolveRecipe } from '../resolve'\nimport type { System } from '../units/registry'\nimport { NUTRIENTS } from './nutrients'\nimport type {\n SchemaOrgHowToSection,\n SchemaOrgHowToStep,\n SchemaOrgNutritionInformation,\n SchemaOrgRecipe,\n ToJsonLdOptions,\n ToJsonLdResult,\n} from './types'\n\n/**\n * Writes a recipe as schema.org Recipe JSON-LD, for search engines' recipe\n * results. Writes it at its own servings and in `recipe.system`, else metric,\n * unless the options say otherwise; invalid options fall back to these, as in\n * `resolveRecipe`. Never throws for an unchanged recipe from `parseRecipe` or\n * `fromJsonLd`, though an error thrown by a getter or proxy in the options\n * passes through.\n *\n * `data` is plain JSON when `extra` is. Write it into a\n * `<script type=\"application/ld+json\">` tag with `<` escaped as `\\u003c`, so\n * recipe text can't end the tag. An `image` passed in `extra` is a URL; for an\n * `ImageObject` or several images, spread `data` into a new object with your\n * own `image`.\n *\n * Ingredient lines read back with `parseIngredientLine`: a quantity's\n * alternatives follow it in parentheses, as in `\"1 ½ cups (190 g) flour,\n * sifted\"` and `\"2 eggs (100 g)\"`, and an optional ingredient ends\n * `\" (optional)\"`. schema.org has no place for ingredient section titles, the\n * recipe's notes, custom times, an ingredient's `url`, or equipment beyond its\n * name, so they aren't written. Reading `data` back with `fromJsonLd` gives a\n * similar recipe, not the same one.\n *\n * `warnings` names the fields Google requires for recipe results that `data`\n * lacks, with paths that use the schema.org names: `image`, and `recipeYield`\n * when there is nutrition. A time longer than `Number.MAX_SAFE_INTEGER`\n * minutes is left out, as `fromJsonLd` leaves it out, and so is a `NaN` time,\n * each with an `invalid_duration` warning at its path in the recipe, such as\n * `['times', 'cook']`.\n *\n * @example\n * const { data } = toJsonLd(recipe, { url: 'https://example.com/pancakes' })\n * data.recipeYield // ['4', '4 servings']\n * data.recipeIngredient // ['1 ½ cups (190 g) flour, sifted', '2 eggs', 'salt, to taste']\n */\nexport function toJsonLd(\n recipe: Recipe,\n options?: ToJsonLdOptions,\n): ToJsonLdResult {\n const { servings, system, url, extra } = options ?? {}\n const resolved = resolveRecipe(recipe, { servings, system })\n // `s`, so a line break doesn't end the fragment.\n const page = typeof url === 'string' ? url.replace(/#.*/s, '') : undefined\n const yields = [\n ...(resolved.servings ? servingsText(resolved.servings) : []),\n ...(resolved.yield\n ? [formatYield(resolved.yield, { fractions: false })]\n : []),\n ]\n const ingredients = resolved.ingredients.flatMap(({ items }) =>\n items.map((item) => ingredientLine(item, resolved.scale, resolved.system)),\n )\n const steps = resolved.steps.flatMap(\n ({ title, items }): (SchemaOrgHowToStep | SchemaOrgHowToSection)[] => {\n const howToSteps = items.map((step): SchemaOrgHowToStep => ({\n '@type': 'HowToStep',\n ...(step.name !== undefined && { name: step.name }),\n text: step.text,\n ...(step.image !== undefined && { image: step.image }),\n ...(page !== undefined && {\n // encodeURIComponent throws on a lone surrogate. With the u flag,\n // \\p{Cs} matches only those, as a pair reads as one code point.\n url: `${page}#${encodeURIComponent(step.id.replace(/\\p{Cs}/gu, '\\uFFFD'))}`,\n }),\n }))\n return title === undefined || howToSteps.length === 0\n ? howToSteps\n : [\n {\n '@type': 'HowToSection',\n name: title,\n itemListElement: howToSteps,\n },\n ]\n },\n )\n const { description, image, times, equipment, nutrition } = recipe\n const warnings: RecipeIssue[] = []\n\n const written: SchemaOrgRecipe = {\n '@context': 'https://schema.org',\n '@type': 'Recipe',\n name: recipe.name,\n ...(description !== undefined && { description }),\n ...(image !== undefined && { image }),\n ...(yields.length > 0 && { recipeYield: yields.map(plainSpaces) }),\n ...duration(warnings, 'prep', times.prep),\n ...duration(warnings, 'cook', times.cook),\n ...duration(warnings, 'total', times.total),\n ...(ingredients.length > 0 && { recipeIngredient: ingredients }),\n ...(steps.length > 0 && { recipeInstructions: steps }),\n ...(equipment.length > 0 && { tool: equipment.map(({ name }) => name) }),\n ...(nutrition !== undefined && {\n nutrition: nutritionInformation(nutrition),\n }),\n }\n const data: SchemaOrgRecipe = {\n ...written,\n ...Object.fromEntries(\n Object.entries(isRecord(extra) ? extra : {}).filter(\n ([field, value]) => value !== undefined && !hasOwn(written, field),\n ),\n ),\n }\n\n if (data.image === undefined) {\n warning(\n warnings,\n 'missing_field',\n ['image'],\n 'Add an image. Google requires one for recipe results.',\n )\n }\n if (data.nutrition !== undefined && data.recipeYield === undefined) {\n warning(\n warnings,\n 'missing_field',\n ['recipeYield'],\n 'Add servings or a yield. Google requires one with nutrition, which is for one serving.',\n )\n }\n\n return { data, warnings }\n}\n\n/** `[\"4\", \"4 servings\"]`, in decimals, so the text reads back as a number. */\nfunction servingsText(servings: {\n amount: number\n max?: number | undefined\n}): string[] {\n const amount = formatServings(servings, { fractions: false })\n const word =\n servings.amount === 1 && servings.max === undefined ? 'serving' : 'servings'\n return [amount, `${amount} ${word}`]\n}\n\n/**\n * The line as `formatIngredient` writes it, with the quantity's alternatives\n * in parentheses after it, or after the name for a bare count, and\n * \" (optional)\" at the end.\n */\nfunction ingredientLine(\n item: ResolvedIngredient,\n scale: number,\n system: System,\n): string {\n const { ingredient, origin } = item\n const { quantity, name, note } = formatIngredient(item)\n const authored = ingredient.quantity\n ? [ingredient.quantity, ...ingredient.equivalents]\n : []\n const shown =\n origin === 'authored'\n ? 0\n : origin === 'equivalent'\n ? 1 + ingredient.equivalents.findIndex((q) => isNative(q, system))\n : -1\n const alternatives = authored\n .filter((_, i) => i !== shown)\n .map((q) => formatQuantity(scale === 1 ? q : multiply(q, scale)))\n .join('; ')\n const inParentheses = alternatives && ` (${alternatives})`\n const head =\n quantity === undefined\n ? name\n : item.quantity?.unit === undefined\n ? `${quantity} ${name}${inParentheses}`\n : `${quantity}${inParentheses} ${name}`\n\n return plainSpaces(\n `${head}${note ? `, ${note}` : ''}${ingredient.optional ? ' (optional)' : ''}`,\n )\n}\n\n/**\n * An ISO 8601 duration to the second, or nothing for an absent or zero time,\n * or one it can't write: `NaN`, which `parseRecipe` rejects but a recipe built\n * by hand can hold, or a time longer than `Number.MAX_SAFE_INTEGER` minutes.\n * Below that bound, the hours fit in a safe integer, so they print in plain\n * digits.\n */\nfunction duration(\n warnings: RecipeIssue[],\n key: 'prep' | 'cook' | 'total',\n minutes: number | undefined,\n): Partial<SchemaOrgRecipe> {\n if (minutes === undefined) {\n return {}\n }\n if (Number.isNaN(minutes) || minutes > Number.MAX_SAFE_INTEGER) {\n warning(\n warnings,\n 'invalid_duration',\n ['times', key],\n `Expected minutes such as 30, received ${describeValue(minutes)}${Number.isNaN(minutes) ? '' : ', which is too long'}. It is ignored.`,\n )\n return {}\n }\n\n const seconds = Math.round(minutes * 60)\n\n if (seconds <= 0) {\n return {}\n }\n\n const parts = [\n [Math.floor(seconds / 3600), 'H'],\n [Math.floor(seconds / 60) % 60, 'M'],\n [seconds % 60, 'S'],\n ] as const\n\n return {\n [`${key}Time`]: `PT${parts.map(([n, unit]) => (n ? `${n}${unit}` : '')).join('')}`,\n }\n}\n\nfunction nutritionInformation(\n nutrition: Nutrition,\n): SchemaOrgNutritionInformation {\n const information: SchemaOrgNutritionInformation = {\n '@type': 'NutritionInformation',\n }\n\n for (const key of Object.keys(NUTRIENTS) as (keyof Nutrition)[]) {\n const value = nutrition[key]\n if (value !== undefined) {\n information[NUTRIENTS[key]] =\n typeof value === 'string'\n ? value\n : `${round(value, 3)} ${NUTRIENT_UNITS[key as NutrientKey]}`\n }\n }\n\n return information\n}\n\nfunction plainSpaces(text: string): string {\n return text.replace(/\\u00A0/g, ' ')\n}\n"],"mappings":"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","names":[]}