@tmlmt/cooklang-parser
Version:
Cooklang parsers and utilities
1 lines • 99.6 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/classes/category_config.ts","../src/classes/section.ts","../node_modules/.pnpm/human-regex@2.1.5_patch_hash=6d6bd9e233f99785a7c2187fd464edc114b76d47001dbb4eb6b5d72168de7460/node_modules/human-regex/src/human-regex.ts","../src/regex.ts","../src/units.ts","../src/errors.ts","../src/parser_helpers.ts","../src/classes/recipe.ts","../src/classes/shopping_list.ts"],"sourcesContent":["import { CategoryConfig } from \"./classes/category_config\";\nimport { Recipe } from \"./classes/recipe\";\nimport { ShoppingList } from \"./classes/shopping_list\";\nimport { Section } from \"./classes/section\";\n\nimport type {\n Metadata,\n Ingredient,\n IngredientFlag,\n IngredientExtras,\n FixedValue,\n Range,\n DecimalValue,\n FractionValue,\n TextValue,\n Timer,\n TextItem,\n IngredientItem,\n CookwareItem,\n TimerItem,\n Item,\n Step,\n Note,\n Cookware,\n CookwareFlag,\n CategorizedIngredients,\n AddedRecipe,\n CategoryIngredient,\n Category,\n QuantityPart,\n} from \"./types\";\n\nexport {\n Recipe,\n ShoppingList,\n CategoryConfig,\n Metadata,\n Ingredient,\n IngredientFlag,\n IngredientExtras,\n FixedValue,\n Range,\n DecimalValue,\n FractionValue,\n TextValue,\n Timer,\n TextItem,\n IngredientItem,\n CookwareItem,\n TimerItem,\n Item,\n Step,\n Note,\n Cookware,\n CookwareFlag,\n CategorizedIngredients,\n AddedRecipe,\n CategoryIngredient,\n Category,\n Section,\n QuantityPart,\n};\n","import type { Category, CategoryIngredient } from \"../types\";\n\n/**\n * Parser for category configurations specified à-la-cooklang.\n *\n * ## Usage\n *\n * You can either directly provide the category configuration string when creating the instance\n * e.g. `const categoryConfig = new CategoryConfig(<...>)`, or create it first and then pass\n * the category configuration string to the {@link CategoryConfig.parse | parse()} method.\n *\n * The initialized `CategoryConfig` can then be fed to a {@link ShoppingList}\n *\n * @example\n * ```typescript\n * import { CategoryConfig } from @tmlmt/cooklang-parser;\n *\n * const categoryConfigString = `\n * [Dairy]\n * milk\n * butter\n *\n * [Bakery]\n * flour\n * sugar`;\n *\n * const categoryConfig = new CategoryConfig(categoryConfigString);\n * ```\n *\n * @see [Category Configuration](https://cooklang.org/docs/spec/#shopping-lists) section of the cooklang specs\n *\n * @category Classes\n */\nexport class CategoryConfig {\n /**\n * The parsed categories of ingredients.\n */\n categories: Category[] = [];\n\n /**\n * Creates a new CategoryConfig instance.\n * @param config - The category configuration to parse.\n */\n constructor(config?: string) {\n if (config) {\n this.parse(config);\n }\n }\n\n /**\n * Parses a category configuration from a string into property\n * {@link CategoryConfig.categories | categories}\n * @param config - The category configuration to parse.\n */\n parse(config: string) {\n let currentCategory: Category | null = null;\n const categoryNames = new Set<string>();\n const ingredientNames = new Set<string>();\n\n for (const line of config.split(\"\\n\")) {\n const trimmedLine = line.trim();\n\n if (trimmedLine.length === 0) {\n continue;\n }\n\n if (trimmedLine.startsWith(\"[\") && trimmedLine.endsWith(\"]\")) {\n const categoryName = trimmedLine\n .substring(1, trimmedLine.length - 1)\n .trim();\n\n if (categoryNames.has(categoryName)) {\n throw new Error(`Duplicate category found: ${categoryName}`);\n }\n categoryNames.add(categoryName);\n\n currentCategory = { name: categoryName, ingredients: [] };\n this.categories.push(currentCategory);\n } else {\n if (currentCategory === null) {\n throw new Error(\n `Ingredient found without a category: ${trimmedLine}`,\n );\n }\n\n const aliases = trimmedLine.split(\"|\").map((s) => s.trim());\n for (const alias of aliases) {\n if (ingredientNames.has(alias)) {\n throw new Error(`Duplicate ingredient/alias found: ${alias}`);\n }\n ingredientNames.add(alias);\n }\n\n const ingredient: CategoryIngredient = {\n name: aliases[0]!, // We know this exists because trimmedLine is not empty\n aliases: aliases,\n };\n currentCategory.ingredients.push(ingredient);\n }\n }\n }\n}\n","import type { Step, Note } from \"../types\";\n\n/**\n * Represents a recipe section\n *\n * Wrapped as a _Class_ and not defined as a simple _Type_ to expose some useful helper\n * classes (e.g. {@link Section.isBlank | isBlank()})\n *\n * @category Types\n */\nexport class Section {\n /**\n * The name of the section. Can be an empty string for the default (first) section.\n * @defaultValue `\"\"`\n */\n name: string;\n /** An array of steps and notes that make up the content of the section. */\n content: (Step | Note)[] = [];\n\n /**\n * Creates an instance of Section.\n * @param name - The name of the section. Defaults to an empty string.\n */\n constructor(name: string = \"\") {\n this.name = name;\n }\n\n /**\n * Checks if the section is blank (has no name and no content).\n * Used during recipe parsing\n * @returns `true` if the section is blank, otherwise `false`.\n */\n isBlank(): boolean {\n return this.name === \"\" && this.content.length === 0;\n }\n}\n","type PartialBut<T, K extends keyof T> = Partial<T> & Pick<T, K>;\n\nconst escapeCache = new Map<string, string>();\n\nconst Flags = {\n GLOBAL: \"g\",\n NON_SENSITIVE: \"i\",\n MULTILINE: \"m\",\n DOT_ALL: \"s\",\n UNICODE: \"u\",\n STICKY: \"y\",\n} as const;\n\nconst Ranges = Object.freeze({\n digit: \"0-9\",\n lowercaseLetter: \"a-z\",\n uppercaseLetter: \"A-Z\",\n letter: \"a-zA-Z\",\n alphanumeric: \"a-zA-Z0-9\",\n anyCharacter: \".\",\n});\n\ntype RangeKeys = keyof typeof Ranges;\n\nconst Quantifiers = Object.freeze({\n zeroOrMore: \"*\",\n oneOrMore: \"+\",\n optional: \"?\",\n});\n\ntype Quantifiers =\n | \"exactly\"\n | \"atLeast\"\n | \"atMost\"\n | \"between\"\n | \"oneOrMore\"\n | \"zeroOrMore\"\n | \"repeat\";\ntype QuantifierMethods = Quantifiers | \"optional\" | \"lazy\";\n\ntype WithLazy = HumanRegex;\ntype Base = Omit<HumanRegex, \"lazy\">;\ntype AtStart = Omit<Base, QuantifierMethods | \"endGroup\">;\ntype AfterAnchor = Omit<Base, QuantifierMethods | \"or\">;\ntype SimpleQuantifier = Omit<Base, Quantifiers>;\ntype LazyQuantifier = Omit<WithLazy, Quantifiers>;\n\nclass HumanRegex {\n private parts: string[];\n private flags: Set<string>;\n\n constructor() {\n this.parts = [];\n this.flags = new Set<string>();\n }\n\n digit(): Base {\n return this.add(\"\\\\d\");\n }\n\n special(): Base {\n return this.add(\"(?=.*[!@#$%^&*])\");\n }\n\n word(): Base {\n return this.add(\"\\\\w\");\n }\n\n whitespace(): Base {\n return this.add(\"\\\\s\");\n }\n\n nonWhitespace(): Base {\n return this.add(\"\\\\S\");\n }\n\n literal(text: string): this {\n return this.add(escapeLiteral(text));\n }\n\n or(): AfterAnchor {\n return this.add(\"|\");\n }\n\n range(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[${range}]`);\n }\n\n notRange(name: RangeKeys): Base {\n const range = Ranges[name];\n if (!range) throw new Error(`Unknown range: ${name}`);\n return this.add(`[^${range}]`);\n }\n\n anyOf(chars: string): Base {\n return this.add(`[${chars}]`);\n }\n\n notAnyOf(chars: string): Base {\n return this.add(`[^${chars}]`);\n }\n\n lazy(): Base {\n const lastPart = this.parts.pop();\n if (!lastPart) throw new Error(\"No quantifier to make lazy\");\n return this.add(`${lastPart}?`);\n }\n\n letter(): Base {\n return this.add(\"[a-zA-Z]\");\n }\n\n anyCharacter(): Base {\n return this.add(\".\");\n }\n\n newline(): Base {\n return this.add(\"(?:\\\\r\\\\n|\\\\r|\\\\n)\"); // Windows: \\r\\n, Unix: \\n, Old Macs: \\r\n }\n\n negativeLookahead(pattern: string): Base {\n return this.add(`(?!${pattern})`);\n }\n\n positiveLookahead(pattern: string): Base {\n return this.add(`(?=${pattern})`);\n }\n\n positiveLookbehind(pattern: string): Base {\n return this.add(`(?<=${pattern})`);\n }\n\n negativeLookbehind(pattern: string): Base {\n return this.add(`(?<!${pattern})`);\n }\n\n hasSpecialCharacter(): Base {\n return this.add(\"(?=.*[!@#$%^&*])\");\n }\n\n hasDigit(): Base {\n return this.add(\"(?=.*\\\\d)\");\n }\n\n hasLetter(): Base {\n return this.add(\"(?=.*[a-zA-Z])\");\n }\n\n optional(): SimpleQuantifier {\n return this.add(Quantifiers.optional);\n }\n\n exactly(n: number): SimpleQuantifier {\n return this.add(`{${n}}`);\n }\n\n atLeast(n: number): LazyQuantifier {\n return this.add(`{${n},}`);\n }\n\n atMost(n: number): LazyQuantifier {\n return this.add(`{0,${n}}`);\n }\n\n between(min: number, max: number): LazyQuantifier {\n return this.add(`{${min},${max}}`);\n }\n\n oneOrMore(): LazyQuantifier {\n return this.add(Quantifiers.oneOrMore);\n }\n\n zeroOrMore(): LazyQuantifier {\n return this.add(Quantifiers.zeroOrMore);\n }\n\n startNamedGroup(name: string): AfterAnchor {\n return this.add(`(?<${name}>`);\n }\n\n startGroup(): AfterAnchor {\n return this.add(\"(?:\");\n }\n\n startCaptureGroup(): AfterAnchor {\n return this.add(\"(\");\n }\n\n wordBoundary(): Base {\n return this.add(\"\\\\b\");\n }\n\n nonWordBoundary(): Base {\n return this.add(\"\\\\B\");\n }\n\n endGroup(): Base {\n return this.add(\")\");\n }\n\n startAnchor(): AfterAnchor {\n return this.add(\"^\");\n }\n\n endAnchor(): AfterAnchor {\n return this.add(\"$\");\n }\n\n global(): this {\n this.flags.add(Flags.GLOBAL);\n return this;\n }\n\n nonSensitive(): this {\n this.flags.add(Flags.NON_SENSITIVE);\n return this;\n }\n\n multiline(): this {\n this.flags.add(Flags.MULTILINE);\n return this;\n }\n\n dotAll(): this {\n this.flags.add(Flags.DOT_ALL);\n return this;\n }\n\n sticky(): this {\n this.flags.add(Flags.STICKY);\n return this;\n }\n\n unicodeChar(variant?: \"u\" | \"l\" | \"t\" | \"m\" | \"o\"): Base {\n this.flags.add(Flags.UNICODE);\n const validVariants = new Set([\"u\", \"l\", \"t\", \"m\", \"o\"] as const);\n\n if (variant !== undefined && !validVariants.has(variant)) {\n throw new Error(`Invalid Unicode letter variant: ${variant}`);\n }\n\n return this.add(`\\\\p{L${variant ?? \"\"}}`);\n }\n\n unicodeDigit(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{N}\");\n }\n\n unicodePunctuation(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{P}\");\n }\n\n unicodeSymbol(): Base {\n this.flags.add(Flags.UNICODE);\n return this.add(\"\\\\p{S}\");\n }\n\n repeat(count: number): Base {\n if (this.parts.length === 0) {\n throw new Error(\"No pattern to repeat\");\n }\n\n const lastPart = this.parts.pop();\n this.parts.push(`(${lastPart}){${count}}`);\n return this;\n }\n\n ipv4Octet(): Base {\n return this.add(\"(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]\\\\d|\\\\d)\");\n }\n\n protocol(): Base {\n return this.add(\"https?://\");\n }\n\n www(): Base {\n return this.add(\"(www\\\\.)?\");\n }\n\n tld(): Base {\n return this.add(\"(com|org|net)\");\n }\n\n path(): Base {\n return this.add(\"(/\\\\w+)*\");\n }\n\n private add(part: string): this {\n this.parts.push(part);\n return this;\n }\n\n toString(): string {\n return this.parts.join(\"\");\n }\n\n toRegExp(): RegExp {\n return new RegExp(this.toString(), [...this.flags].join(\"\"));\n }\n}\n\nfunction escapeLiteral(text: string): string {\n if (!escapeCache.has(text)) {\n escapeCache.set(text, text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n }\n return escapeCache.get(text)!;\n}\n\nconst createRegex = (): AtStart => new HumanRegex();\n\nconst Patterns = (() => {\n const createCachedPattern = (\n builder: () => PartialBut<HumanRegex, \"toRegExp\">\n ) => {\n const regex = builder().toRegExp();\n return () => new RegExp(regex.source, regex.flags);\n };\n\n return {\n email: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .word()\n .oneOrMore()\n .literal(\"@\")\n .word()\n .oneOrMore()\n .startGroup()\n .literal(\".\")\n .word()\n .oneOrMore()\n .endGroup()\n .zeroOrMore()\n .literal(\".\")\n .letter()\n .atLeast(2)\n .endAnchor()\n ),\n url: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .protocol()\n .www()\n .word()\n .oneOrMore()\n .literal(\".\")\n .tld()\n .path()\n .endAnchor()\n ),\n phoneInternational: createCachedPattern(() =>\n createRegex()\n .startAnchor()\n .literal(\"+\")\n .digit()\n .between(1, 3)\n .literal(\"-\")\n .digit()\n .between(3, 14)\n .endAnchor()\n ),\n };\n})();\n\nexport { createRegex, Patterns, Flags, Ranges, Quantifiers };\n","import { createRegex } from \"human-regex\";\n\nexport const metadataRegex = createRegex()\n .literal(\"---\").newline()\n .startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup()\n .newline().literal(\"---\")\n .dotAll().toRegExp();\n\nexport const scalingMetaValueRegex = (varName: string): RegExp => createRegex()\n .startAnchor()\n .literal(varName)\n .literal(\":\")\n .anyOf(\"\\\\t \").zeroOrMore()\n .startCaptureGroup()\n .startCaptureGroup()\n .notAnyOf(\",\\\\n\").oneOrMore()\n .endGroup()\n .startGroup()\n .literal(\",\")\n .whitespace().zeroOrMore()\n .startCaptureGroup()\n .anyCharacter().oneOrMore()\n .endGroup()\n .endGroup().optional()\n .endGroup()\n .endAnchor()\n .multiline()\n .toRegExp()\n\nconst nonWordChar = \"\\\\s@#~\\\\[\\\\]{(,;:!?\"\n\nconst multiwordIngredient = createRegex()\n .literal(\"@\")\n .startNamedGroup(\"mIngredientModifiers\")\n .anyOf(\"@\\\\-&?\").zeroOrMore()\n .endGroup().optional()\n .startNamedGroup(\"mIngredientRecipeAnchor\")\n .literal(\"./\")\n .endGroup().optional()\n .startNamedGroup(\"mIngredientName\")\n .notAnyOf(nonWordChar).oneOrMore()\n .startGroup()\n .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore()\n .endGroup().oneOrMore()\n .endGroup()\n .positiveLookahead(\"\\\\s*(?:\\\\{[^\\\\}]*\\\\}|\\\\([^)]*\\\\))\")\n .startGroup()\n .literal(\"{\")\n .startNamedGroup(\"mIngredientQuantityModifier\")\n .literal(\"=\").exactly(1)\n .endGroup().optional()\n .startNamedGroup(\"mIngredientQuantity\")\n .notAnyOf(\"}%\").oneOrMore()\n .endGroup().optional()\n .startGroup()\n .literal(\"%\")\n .startNamedGroup(\"mIngredientUnit\")\n .notAnyOf(\"}\").oneOrMore().lazy()\n .endGroup()\n .endGroup().optional()\n .literal(\"}\")\n .endGroup().optional()\n .startGroup()\n .literal(\"(\")\n .startNamedGroup(\"mIngredientPreparation\")\n .notAnyOf(\")\").oneOrMore().lazy()\n .endGroup()\n .literal(\")\")\n .endGroup().optional()\n .toRegExp();\n\nconst singleWordIngredient = createRegex()\n .literal(\"@\")\n .startNamedGroup(\"sIngredientModifiers\")\n .anyOf(\"@\\\\-&?\").zeroOrMore()\n .endGroup().optional()\n .startNamedGroup(\"sIngredientRecipeAnchor\")\n .literal(\"./\")\n .endGroup().optional()\n .startNamedGroup(\"sIngredientName\")\n .notAnyOf(nonWordChar).oneOrMore()\n .endGroup()\n .startGroup()\n .literal(\"{\")\n .startNamedGroup(\"sIngredientQuantityModifier\")\n .literal(\"=\").exactly(1)\n .endGroup().optional()\n .startNamedGroup(\"sIngredientQuantity\")\n .notAnyOf(\"}%\").oneOrMore()\n .endGroup().optional()\n .startGroup()\n .literal(\"%\")\n .startNamedGroup(\"sIngredientUnit\")\n .notAnyOf(\"}\").oneOrMore().lazy()\n .endGroup()\n .endGroup().optional()\n .literal(\"}\")\n .endGroup().optional()\n .startGroup()\n .literal(\"(\")\n .startNamedGroup(\"sIngredientPreparation\")\n .notAnyOf(\")\").oneOrMore().lazy()\n .endGroup()\n .literal(\")\")\n .endGroup().optional()\n .toRegExp();\n\nexport const ingredientAliasRegex = createRegex()\n .startAnchor()\n .startNamedGroup(\"ingredientListName\")\n .notAnyOf(\"|\").oneOrMore()\n .endGroup()\n .literal(\"|\")\n .startNamedGroup(\"ingredientDisplayName\")\n .notAnyOf(\"|\").oneOrMore()\n .endGroup()\n .endAnchor()\n .toRegExp();\n\nconst multiwordCookware = createRegex()\n .literal(\"#\")\n .startNamedGroup(\"mCookwareModifiers\")\n .anyOf(\"\\\\-&?\").zeroOrMore()\n .endGroup()\n .startNamedGroup(\"mCookwareName\")\n .notAnyOf(nonWordChar).oneOrMore()\n .startGroup()\n .whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore()\n .endGroup().oneOrMore()\n .endGroup().positiveLookahead(\"\\\\s*(?:\\\\{[^\\\\}]*\\\\})\")\n .literal(\"{\")\n .startNamedGroup(\"mCookwareQuantity\")\n .anyCharacter().zeroOrMore().lazy()\n .endGroup()\n .literal(\"}\")\n .toRegExp();\n\nconst singleWordCookware = createRegex()\n .literal(\"#\")\n .startNamedGroup(\"sCookwareModifiers\")\n .anyOf(\"\\\\-&?\").zeroOrMore()\n .endGroup()\n .startNamedGroup(\"sCookwareName\")\n .notAnyOf(nonWordChar).oneOrMore()\n .endGroup()\n .startGroup()\n .literal(\"{\")\n .startNamedGroup(\"sCookwareQuantity\")\n .anyCharacter().zeroOrMore().lazy()\n .endGroup()\n .literal(\"}\")\n .endGroup().optional()\n .toRegExp();\n\nconst timer = createRegex()\n .literal(\"~\")\n .startNamedGroup(\"timerName\")\n .anyCharacter().zeroOrMore().lazy()\n .endGroup()\n .literal(\"{\")\n .startNamedGroup(\"timerQuantity\")\n .anyCharacter().oneOrMore().lazy()\n .endGroup()\n .startGroup()\n .literal(\"%\")\n .startNamedGroup(\"timerUnit\")\n .anyCharacter().oneOrMore().lazy()\n .endGroup()\n .endGroup().optional()\n .literal(\"}\")\n .toRegExp()\n\nexport const tokensRegex = new RegExp(\n [\n multiwordIngredient,\n singleWordIngredient,\n multiwordCookware,\n singleWordCookware,\n timer,\n ]\n .map((r) => r.source)\n .join(\"|\"),\n \"gu\",\n);\n\nexport const commentRegex = createRegex()\n .literal(\"--\")\n .anyCharacter().zeroOrMore()\n .global()\n .toRegExp();\n\nexport const blockCommentRegex = createRegex()\n .whitespace().zeroOrMore()\n .literal(\"[-\")\n .anyCharacter().zeroOrMore().lazy()\n .literal(\"-]\")\n .whitespace().zeroOrMore()\n .global()\n .toRegExp();\n\nexport const shoppingListRegex = createRegex()\n .literal(\"[\")\n .startNamedGroup(\"name\")\n .anyCharacter().oneOrMore()\n .endGroup()\n .literal(\"]\")\n .newline()\n .startNamedGroup(\"items\")\n .anyCharacter().zeroOrMore().lazy()\n .endGroup()\n .startGroup()\n .newline().newline()\n .or()\n .endAnchor()\n .endGroup()\n .global()\n .toRegExp()\n\nexport const rangeRegex = createRegex()\n .startAnchor()\n .digit().oneOrMore()\n .startGroup()\n .anyOf(\".,/\").exactly(1)\n .digit().oneOrMore()\n .endGroup().optional()\n .literal(\"-\")\n .digit().oneOrMore()\n .startGroup()\n .anyOf(\".,/\").exactly(1)\n .digit().oneOrMore()\n .endGroup().optional()\n .endAnchor()\n .toRegExp()\n\nexport const numberLikeRegex = createRegex()\n .startAnchor()\n .digit().oneOrMore()\n .startGroup()\n .anyOf(\".,/\").exactly(1)\n .digit().oneOrMore()\n .endGroup().optional()\n .endAnchor()\n .toRegExp()\n\nexport const floatRegex = createRegex()\n .startAnchor()\n .digit().oneOrMore()\n .startGroup()\n .anyOf(\".\").exactly(1)\n .digit().oneOrMore()\n .endGroup().optional()\n .endAnchor()\n .toRegExp()","import type { FixedValue, Range, DecimalValue, FractionValue } from \"./types\";\nexport type UnitType = \"mass\" | \"volume\" | \"count\";\nexport type UnitSystem = \"metric\" | \"imperial\";\n\nexport interface UnitDefinition {\n name: string; // canonical name, e.g. 'g'\n type: UnitType;\n system: UnitSystem;\n aliases: string[]; // e.g. ['gram', 'grams']\n toBase: number; // conversion factor to the base unit of its type\n}\n\nexport interface Quantity {\n value: FixedValue | Range;\n unit?: string;\n}\n\n// Base units: mass -> gram (g), volume -> milliliter (ml)\nconst units: UnitDefinition[] = [\n // Mass (Metric)\n {\n name: \"g\",\n type: \"mass\",\n system: \"metric\",\n aliases: [\"gram\", \"grams\", \"grammes\"],\n toBase: 1,\n },\n {\n name: \"kg\",\n type: \"mass\",\n system: \"metric\",\n aliases: [\"kilogram\", \"kilograms\", \"kilogrammes\", \"kilos\", \"kilo\"],\n toBase: 1000,\n },\n // Mass (Imperial)\n {\n name: \"oz\",\n type: \"mass\",\n system: \"imperial\",\n aliases: [\"ounce\", \"ounces\"],\n toBase: 28.3495,\n },\n {\n name: \"lb\",\n type: \"mass\",\n system: \"imperial\",\n aliases: [\"pound\", \"pounds\"],\n toBase: 453.592,\n },\n\n // Volume (Metric)\n {\n name: \"ml\",\n type: \"volume\",\n system: \"metric\",\n aliases: [\"milliliter\", \"milliliters\", \"millilitre\", \"millilitres\", \"cc\"],\n toBase: 1,\n },\n {\n name: \"l\",\n type: \"volume\",\n system: \"metric\",\n aliases: [\"liter\", \"liters\", \"litre\", \"litres\"],\n toBase: 1000,\n },\n {\n name: \"tsp\",\n type: \"volume\",\n system: \"metric\",\n aliases: [\"teaspoon\", \"teaspoons\"],\n toBase: 5,\n },\n {\n name: \"tbsp\",\n type: \"volume\",\n system: \"metric\",\n aliases: [\"tablespoon\", \"tablespoons\"],\n toBase: 15,\n },\n\n // Volume (Imperial)\n {\n name: \"fl-oz\",\n type: \"volume\",\n system: \"imperial\",\n aliases: [\"fluid ounce\", \"fluid ounces\"],\n toBase: 29.5735,\n },\n {\n name: \"cup\",\n type: \"volume\",\n system: \"imperial\",\n aliases: [\"cups\"],\n toBase: 236.588,\n },\n {\n name: \"pint\",\n type: \"volume\",\n system: \"imperial\",\n aliases: [\"pints\"],\n toBase: 473.176,\n },\n {\n name: \"quart\",\n type: \"volume\",\n system: \"imperial\",\n aliases: [\"quarts\"],\n toBase: 946.353,\n },\n {\n name: \"gallon\",\n type: \"volume\",\n system: \"imperial\",\n aliases: [\"gallons\"],\n toBase: 3785.41,\n },\n\n // Count units (no conversion, but recognized as a type)\n {\n name: \"piece\",\n type: \"count\",\n system: \"metric\",\n aliases: [\"pieces\", \"pc\"],\n toBase: 1,\n },\n];\n\nconst unitMap = new Map<string, UnitDefinition>();\nfor (const unit of units) {\n unitMap.set(unit.name.toLowerCase(), unit);\n for (const alias of unit.aliases) {\n unitMap.set(alias.toLowerCase(), unit);\n }\n}\n\nexport function normalizeUnit(unit: string = \"\"): UnitDefinition | undefined {\n return unitMap.get(unit.toLowerCase().trim());\n}\n\nexport class CannotAddTextValueError extends Error {\n constructor() {\n super(\"Cannot add a quantity with a text value.\");\n this.name = \"CannotAddTextValueError\";\n }\n}\n\nexport class IncompatibleUnitsError extends Error {\n constructor(unit1: string, unit2: string) {\n super(\n `Cannot add quantities with incompatible or unknown units: ${unit1} and ${unit2}`,\n );\n this.name = \"IncompatibleUnitsError\";\n }\n}\n\nfunction gcd(a: number, b: number): number {\n return b === 0 ? a : gcd(b, a % b);\n}\n\nexport function simplifyFraction(\n num: number,\n den: number,\n): DecimalValue | FractionValue {\n if (den === 0) {\n throw new Error(\"Denominator cannot be zero.\");\n }\n\n const commonDivisor = gcd(Math.abs(num), Math.abs(den));\n let simplifiedNum = num / commonDivisor;\n let simplifiedDen = den / commonDivisor;\n if (simplifiedDen < 0) {\n simplifiedNum = -simplifiedNum;\n simplifiedDen = -simplifiedDen;\n }\n\n if (simplifiedDen === 1) {\n return { type: \"decimal\", value: simplifiedNum };\n } else {\n return { type: \"fraction\", num: simplifiedNum, den: simplifiedDen };\n }\n}\n\nexport function multiplyNumericValue(\n v: DecimalValue | FractionValue,\n factor: number,\n): DecimalValue | FractionValue {\n if (v.type === \"decimal\") {\n return { type: \"decimal\", value: v.value * factor };\n }\n return simplifyFraction(v.num * factor, v.den);\n}\n\nexport function addNumericValues(\n val1: DecimalValue | FractionValue,\n val2: DecimalValue | FractionValue,\n): DecimalValue | FractionValue {\n let num1: number;\n let den1: number;\n let num2: number;\n let den2: number;\n\n if (val1.type === \"decimal\") {\n num1 = val1.value;\n den1 = 1;\n } else {\n num1 = val1.num;\n den1 = val1.den;\n }\n\n if (val2.type === \"decimal\") {\n num2 = val2.value;\n den2 = 1;\n } else {\n num2 = val2.num;\n den2 = val2.den;\n }\n\n // Return 0 if both values are 0\n if (num1 === 0 && num2 === 0) {\n return { type: \"decimal\", value: 0 };\n }\n\n // We only return a fraction where both input values are fractions themselves or only one while the other is 0\n if (\n (val1.type === \"fraction\" && val2.type === \"fraction\") ||\n (val1.type === \"fraction\" && val2.type === \"decimal\" && val2.value === 0) ||\n (val2.type === \"fraction\" && val1.type === \"decimal\" && val1.value === 0)\n ) {\n const commonDen = den1 * den2;\n const sumNum = num1 * den2 + num2 * den1;\n return simplifyFraction(sumNum, commonDen);\n } else {\n return { type: \"decimal\", value: num1 / den1 + num2 / den2 };\n }\n}\n\nconst toRoundedDecimal = (v: DecimalValue | FractionValue): DecimalValue => {\n const value = v.type === \"decimal\" ? v.value : v.num / v.den;\n return { type: \"decimal\", value: Math.floor(value * 100) / 100 };\n};\n\nexport function multiplyQuantityValue(\n value: FixedValue | Range,\n factor: number,\n): FixedValue | Range {\n if (value.type === \"fixed\") {\n const newValue = multiplyNumericValue(\n value.value as DecimalValue | FractionValue,\n factor,\n );\n if (\n factor === parseInt(factor.toString()) || // e.g. 2 === int\n 1 / factor === parseInt((1 / factor).toString()) // e.g. 0.25 => 4 === int\n ) {\n // Preserve fractions\n return {\n type: \"fixed\",\n value: newValue,\n };\n }\n // We might multiply with big decimal number so rounding into decimal value\n return {\n type: \"fixed\",\n value: toRoundedDecimal(newValue),\n };\n }\n\n return {\n type: \"range\",\n min: toRoundedDecimal(multiplyNumericValue(value.min, factor)),\n max: toRoundedDecimal(multiplyNumericValue(value.max, factor)),\n };\n}\n\nconst convertQuantityValue = (\n value: FixedValue | Range,\n def: UnitDefinition,\n targetDef: UnitDefinition,\n): FixedValue | Range => {\n if (def.name === targetDef.name) return value;\n\n const factor = def.toBase / targetDef.toBase;\n\n return multiplyQuantityValue(value, factor);\n};\n\n/**\n * Get the default / neutral quantity which can be provided to addQuantity\n * for it to return the other value as result\n *\n * @return zero\n */\nexport function getDefaultQuantityValue(): FixedValue {\n return { type: \"fixed\", value: { type: \"decimal\", value: 0 } };\n}\n\n/**\n * Adds two quantity values together.\n *\n * - Adding two {@link FixedValue}s returns a new {@link FixedValue}.\n * - Adding a {@link Range} to any value returns a {@link Range}.\n *\n * @param v1 - The first quantity value.\n * @param v2 - The second quantity value.\n * @returns A new quantity value representing the sum.\n */\nexport function addQuantityValues(v1: FixedValue, v2: FixedValue): FixedValue;\nexport function addQuantityValues(\n v1: FixedValue | Range,\n v2: FixedValue | Range,\n): Range;\n\nexport function addQuantityValues(\n v1: FixedValue | Range,\n v2: FixedValue | Range,\n): FixedValue | Range {\n if (\n (v1.type === \"fixed\" && v1.value.type === \"text\") ||\n (v2.type === \"fixed\" && v2.value.type === \"text\")\n ) {\n throw new CannotAddTextValueError();\n }\n\n if (v1.type === \"fixed\" && v2.type === \"fixed\") {\n const res = addNumericValues(\n v1.value as DecimalValue | FractionValue,\n v2.value as DecimalValue | FractionValue,\n );\n return { type: \"fixed\", value: res };\n }\n const r1 =\n v1.type === \"range\" ? v1 : { type: \"range\", min: v1.value, max: v1.value };\n const r2 =\n v2.type === \"range\" ? v2 : { type: \"range\", min: v2.value, max: v2.value };\n const newMin = addNumericValues(\n r1.min as DecimalValue | FractionValue,\n r2.min as DecimalValue | FractionValue,\n );\n const newMax = addNumericValues(\n r1.max as DecimalValue | FractionValue,\n r2.max as DecimalValue | FractionValue,\n );\n return { type: \"range\", min: newMin, max: newMax };\n}\n\n/**\n * Adds two quantities, returning the result in the most appropriate unit.\n */\nexport function addQuantities(q1: Quantity, q2: Quantity): Quantity {\n const v1 = q1.value;\n const v2 = q2.value;\n\n // Case 1: one of the two values is a text, we throw an error we can catch on the other end\n if (\n (v1.type === \"fixed\" && v1.value.type === \"text\") ||\n (v2.type === \"fixed\" && v2.value.type === \"text\")\n ) {\n throw new CannotAddTextValueError();\n }\n\n const unit1Def = normalizeUnit(q1.unit);\n const unit2Def = normalizeUnit(q2.unit);\n\n const addQuantityValuesAndSetUnit = (\n val1: FixedValue | Range,\n val2: FixedValue | Range,\n unit: string | undefined,\n ): Quantity => ({ value: addQuantityValues(val1, val2), unit });\n\n // Case 2: one of the two values doesn't have a unit, we preserve its value and consider its unit to be that of the other one\n // If at least one of the two units is \"\", this preserves it versus setting the resulting unit as undefined\n if ((q1.unit === \"\" || q1.unit === undefined) && q2.unit !== undefined) {\n return addQuantityValuesAndSetUnit(v1, v2, q2.unit); // Prefer q2's unit\n }\n if ((q2.unit === \"\" || q2.unit === undefined) && q1.unit !== undefined) {\n return addQuantityValuesAndSetUnit(v1, v2, q1.unit); // Prefer q1's unit\n }\n\n // Case 3: the two quantities have the exact same unit\n if (\n (!q1.unit && !q2.unit) ||\n (q1.unit && q2.unit && q1.unit.toLowerCase() === q2.unit.toLowerCase())\n ) {\n return addQuantityValuesAndSetUnit(v1, v2, q1.unit);\n }\n\n // Case 4: the two quantities have different units of known type\n if (unit1Def && unit2Def) {\n // Case 4.1: different unit type => we can't add quantities\n\n if (unit1Def.type !== unit2Def.type) {\n throw new IncompatibleUnitsError(\n `${unit1Def.type} (${q1.unit})`,\n `${unit2Def.type} (${q2.unit})`,\n );\n }\n\n let targetUnitDef: UnitDefinition;\n\n // Case 4.2: same unit type but different system => we convert to metric\n if (unit1Def.system !== unit2Def.system) {\n const metricUnitDef = unit1Def.system === \"metric\" ? unit1Def : unit2Def;\n targetUnitDef = units\n .filter((u) => u.type === metricUnitDef.type && u.system === \"metric\")\n .reduce((prev, current) =>\n prev.toBase > current.toBase ? prev : current,\n );\n }\n // Case 4.3: same unit type, same system but different unit => we use the biggest unit of the two\n else {\n targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;\n }\n const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);\n const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);\n\n return addQuantityValuesAndSetUnit(\n convertedV1,\n convertedV2,\n targetUnitDef.name,\n );\n }\n\n // Case 5: the two quantities have different units of unknown type\n throw new IncompatibleUnitsError(q1.unit!, q2.unit!);\n}\n","import { IngredientFlag, CookwareFlag } from \"./types\";\n\nexport class ReferencedItemCannotBeRedefinedError extends Error {\n constructor(\n item_type: \"ingredient\" | \"cookware\",\n item_name: string,\n new_modifier: IngredientFlag | CookwareFlag,\n ) {\n super(\n `The referenced ${item_type} \"${item_name}\" cannot be redefined as ${new_modifier}.\nYou can either remove the reference to create a new ${item_type} defined as ${new_modifier} or add the ${new_modifier} flag to the original definition of the ${item_type}`,\n );\n this.name = \"ReferencedItemCannotBeRedefinedError\";\n }\n}\n","import type {\n MetadataExtract,\n Metadata,\n FixedValue,\n Range,\n TextValue,\n DecimalValue,\n FractionValue,\n} from \"./types\";\nimport {\n metadataRegex,\n rangeRegex,\n numberLikeRegex,\n scalingMetaValueRegex,\n} from \"./regex\";\nimport { Section as SectionObject } from \"./classes/section\";\nimport type { Ingredient, Note, Step, Cookware } from \"./types\";\nimport {\n addQuantities,\n getDefaultQuantityValue,\n CannotAddTextValueError,\n IncompatibleUnitsError,\n Quantity,\n addQuantityValues,\n} from \"./units\";\nimport { ReferencedItemCannotBeRedefinedError } from \"./errors\";\n\n/**\n * Pushes a pending note to the section content if it's not empty.\n * @param section - The current section object.\n * @param note - The note content.\n * @returns An empty string if the note was pushed, otherwise the original note.\n */\nexport function flushPendingNote(\n section: SectionObject,\n note: Note[\"note\"],\n): Note[\"note\"] {\n if (note.length > 0) {\n section.content.push({ type: \"note\", note });\n return \"\";\n }\n return note;\n}\n\n/**\n * Pushes pending step items and a pending note to the section content.\n * @param section - The current section object.\n * @param items - The list of step items. This array will be cleared.\n * @returns true if the items were pushed, otherwise false.\n */\nexport function flushPendingItems(\n section: SectionObject,\n items: Step[\"items\"],\n): boolean {\n if (items.length > 0) {\n section.content.push({ type: \"step\", items: [...items] });\n items.length = 0;\n return true;\n }\n return false;\n}\n\n/**\n * Finds an ingredient in the list (case-insensitively) and updates it, or adds it if not present.\n * This function mutates the `ingredients` array.\n * @param ingredients - The list of ingredients.\n * @param newIngredient - The ingredient to find or add.\n * @param isReference - Whether this is a reference ingredient (`&` modifier).\n * @returns The index of the ingredient in the list.\n * @returns An object containing the index of the ingredient and its quantity part in the list.\n */\nexport function findAndUpsertIngredient(\n ingredients: Ingredient[],\n newIngredient: Ingredient,\n isReference: boolean,\n): {\n ingredientIndex: number;\n quantityPartIndex: number | undefined;\n} {\n const { name, quantity, unit } = newIngredient;\n\n // New ingredient\n if (isReference) {\n const indexFind = ingredients.findIndex(\n (i) => i.name.toLowerCase() === name.toLowerCase(),\n );\n\n if (indexFind === -1) {\n throw new Error(\n `Referenced ingredient \"${name}\" not found. A referenced ingredient must be declared before being referenced with '&'.`,\n );\n }\n\n // Ingredient already exists, update it\n const existingIngredient = ingredients[indexFind]!;\n\n // Checking whether any provided flags are the same as the original ingredient\n for (const flag of newIngredient.flags!) {\n /* v8 ignore else -- @preserve */\n if (!existingIngredient.flags!.includes(flag)) {\n throw new ReferencedItemCannotBeRedefinedError(\n \"ingredient\",\n existingIngredient.name,\n flag,\n );\n }\n }\n\n let quantityPartIndex = undefined;\n if (quantity !== undefined) {\n const currentQuantity: Quantity = {\n value: existingIngredient.quantity ?? getDefaultQuantityValue(),\n unit: existingIngredient.unit ?? \"\",\n };\n const newQuantity = { value: quantity, unit: unit ?? \"\" };\n try {\n const total = addQuantities(currentQuantity, newQuantity);\n existingIngredient.quantity = total.value;\n existingIngredient.unit = total.unit || undefined;\n if (existingIngredient.quantityParts) {\n existingIngredient.quantityParts.push(\n ...newIngredient.quantityParts!,\n );\n } else {\n existingIngredient.quantityParts = newIngredient.quantityParts;\n }\n quantityPartIndex = existingIngredient.quantityParts!.length - 1;\n } catch (e) {\n /* v8 ignore else -- expliciting error types -- @preserve */\n if (\n e instanceof IncompatibleUnitsError ||\n e instanceof CannotAddTextValueError\n ) {\n // Addition not possible, so add as a new ingredient.\n return {\n ingredientIndex: ingredients.push(newIngredient) - 1,\n quantityPartIndex: 0,\n };\n }\n }\n }\n return {\n ingredientIndex: indexFind,\n quantityPartIndex,\n };\n }\n\n // Not a reference, so add as a new ingredient.\n return {\n ingredientIndex: ingredients.push(newIngredient) - 1,\n quantityPartIndex: 0,\n };\n}\n\nexport function findAndUpsertCookware(\n cookware: Cookware[],\n newCookware: Cookware,\n isReference: boolean,\n): {\n cookwareIndex: number;\n quantityPartIndex: number | undefined;\n} {\n const { name, quantity } = newCookware;\n\n if (isReference) {\n const index = cookware.findIndex(\n (i) => i.name.toLowerCase() === name.toLowerCase(),\n );\n\n if (index === -1) {\n throw new Error(\n `Referenced cookware \"${name}\" not found. A referenced cookware must be declared before being referenced with '&'.`,\n );\n }\n\n const existingCookware = cookware[index]!;\n\n // Checking whether any provided flags are the same as the original cookware\n for (const flag of newCookware.flags) {\n /* v8 ignore else -- @preserve */\n if (!existingCookware.flags.includes(flag)) {\n throw new ReferencedItemCannotBeRedefinedError(\n \"cookware\",\n existingCookware.name,\n flag,\n );\n }\n }\n\n let quantityPartIndex = undefined;\n if (quantity !== undefined) {\n if (!existingCookware.quantity) {\n existingCookware.quantity = quantity;\n existingCookware.quantityParts = newCookware.quantityParts;\n quantityPartIndex = 0;\n } else {\n try {\n existingCookware.quantity = addQuantityValues(\n existingCookware.quantity,\n quantity,\n );\n if (!existingCookware.quantityParts) {\n existingCookware.quantityParts = newCookware.quantityParts;\n quantityPartIndex = 0;\n } else {\n quantityPartIndex =\n existingCookware.quantityParts.push(\n ...newCookware.quantityParts!,\n ) - 1;\n }\n } catch (e) {\n /* v8 ignore else -- expliciting error type -- @preserve */\n if (e instanceof CannotAddTextValueError) {\n return {\n cookwareIndex: cookware.push(newCookware) - 1,\n quantityPartIndex: 0,\n };\n }\n }\n }\n }\n return {\n cookwareIndex: index,\n quantityPartIndex,\n };\n }\n\n return {\n cookwareIndex: cookware.push(newCookware) - 1,\n quantityPartIndex: quantity ? 0 : undefined,\n };\n}\n\n// Parser when we know the input is either a number-like value\nexport const parseFixedValue = (\n input_str: string,\n): TextValue | DecimalValue | FractionValue => {\n if (!numberLikeRegex.test(input_str)) {\n return { type: \"text\", value: input_str };\n }\n\n // After this we know that s is either a fraction or a decimal value\n const s = input_str.trim().replace(\",\", \".\");\n\n // fraction\n if (s.includes(\"/\")) {\n const parts = s.split(\"/\");\n\n const num = Number(parts[0]);\n const den = Number(parts[1]);\n\n return { type: \"fraction\", num, den };\n }\n\n // decimal\n return { type: \"decimal\", value: Number(s) };\n};\n\nexport function parseQuantityInput(input_str: string): FixedValue | Range {\n const clean_str = String(input_str).trim();\n\n if (rangeRegex.test(clean_str)) {\n const range_parts = clean_str.split(\"-\");\n // As we've tested for it, we know that we have Number-like Quantities to parse\n const min = parseFixedValue(range_parts[0]!.trim()) as\n | DecimalValue\n | FractionValue;\n const max = parseFixedValue(range_parts[1]!.trim()) as\n | DecimalValue\n | FractionValue;\n return { type: \"range\", min, max };\n }\n\n return { type: \"fixed\", value: parseFixedValue(clean_str) };\n}\n\nexport function parseSimpleMetaVar(content: string, varName: string) {\n const varMatch = content.match(\n new RegExp(`^${varName}:\\\\s*(.*(?:\\\\r?\\\\n\\\\s+.*)*)+`, \"m\"),\n );\n return varMatch\n ? varMatch[1]?.trim().replace(/\\s*\\r?\\n\\s+/g, \" \")\n : undefined;\n}\n\nexport function parseScalingMetaVar(\n content: string,\n varName: string,\n): [number, string] | undefined {\n const varMatch = content.match(scalingMetaValueRegex(varName));\n if (!varMatch) return undefined;\n if (isNaN(Number(varMatch[2]?.trim()))) {\n throw new Error(\"Scaling variables should be numbers\");\n }\n return [Number(varMatch[2]?.trim()), varMatch[1]!.trim()];\n}\n\nexport function parseListMetaVar(content: string, varName: string) {\n // Handle both inline and YAML-style tags\n const listMatch = content.match(\n new RegExp(\n `^${varName}:\\\\s*(?:\\\\[([^\\\\]]*)\\\\]|((?:\\\\r?\\\\n\\\\s*-\\\\s*.+)+))`,\n \"m\",\n ),\n );\n if (!listMatch) return undefined;\n\n /* v8 ignore else -- @preserve */\n if (listMatch[1] !== undefined) {\n // Inline list: tags: [one, two, three]\n return listMatch[1].split(\",\").map((tag) => tag.trim());\n } else if (listMatch[2]) {\n // YAML list:\n // tags:\n // - one\n // - two\n return listMatch[2]\n .split(\"\\n\")\n .filter((line) => line.trim() !== \"\")\n .map((line) => line.replace(/^\\s*-\\s*/, \"\").trim());\n }\n}\n\nexport function extractMetadata(content: string): MetadataExtract {\n const metadata: Metadata = {};\n let servings: number | undefined = undefined;\n\n // Is there front-matter at all?\n const metadataContent = content.match(metadataRegex)?.[1];\n if (!metadataContent) {\n return { metadata };\n }\n\n // String metadata variables\n for (const metaVar of [\n \"title\",\n \"source\",\n \"source.name\",\n \"source.url\",\n \"author\",\n \"source.author\",\n \"prep time\",\n \"time.prep\",\n \"cook time\",\n \"time.cook\",\n \"time required\",\n \"time\",\n \"duration\",\n \"locale\",\n \"introduction\",\n \"description\",\n \"course\",\n \"category\",\n \"diet\",\n \"cuisine\",\n \"difficulty\",\n \"image\",\n \"picture\",\n ] as (keyof Pick<\n Metadata,\n | \"title\"\n | \"source\"\n | \"source.name\"\n | \"source.url\"\n | \"author\"\n | \"source.author\"\n | \"prep time\"\n | \"time.prep\"\n | \"cook time\"\n | \"time.cook\"\n | \"time required\"\n | \"time\"\n | \"duration\"\n | \"locale\"\n | \"introduction\"\n | \"description\"\n | \"course\"\n | \"category\"\n | \"diet\"\n | \"cuisine\"\n | \"difficulty\"\n | \"image\"\n | \"picture\"\n >)[]) {\n const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);\n if (stringMetaValue) metadata[metaVar] = stringMetaValue;\n }\n\n // String metadata variables\n for (const metaVar of [\"serves\", \"yield\", \"servings\"] as (keyof Pick<\n Metadata,\n \"servings\" | \"yield\" | \"serves\"\n >)[]) {\n const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);\n if (scalingMetaValue && scalingMetaValue[1]) {\n metadata[metaVar] = scalingMetaValue[1];\n servings = scalingMetaValue[0];\n }\n }\n\n // List metadata variables\n for (const metaVar of [\"tags\", \"images\", \"pictures\"] as (keyof Pick<\n Metadata,\n \"tags\" | \"images\" | \"pictures\"\n >)[]) {\n const listMetaValue = parseListMetaVar(metadataContent, metaVar);\n if (listMetaValue) metadata[metaVar] = listMetaValue;\n }\n\n return { metadata, servings };\n}\n","import type {\n Metadata,\n Ingredient,\n IngredientExtras,\n IngredientItem,\n Timer,\n Step,\n Note,\n Cookware,\n MetadataExtract,\n CookwareItem,\n IngredientFlag,\n CookwareFlag,\n} from \"../types\";\nimport { Section } from \"./section\";\nimport {\n tokensRegex,\n commentRegex,\n blockCommentRegex,\n metadataRegex,\n ingredientAliasRegex,\n floatRegex,\n} from \"../regex\";\nimport {\n flushPendingItems,\n flushPendingNote,\n findAndUpsertIngredient,\n findAndUpsertCookware,\n parseQuantityInput,\n extractMetadata,\n} from \"../parser_helpers\";\nimport {\n addQuantities,\n getDefaultQuantityValue,\n multiplyQuantityValue,\n type Quantity,\n} from \"../units\";\n\n/**\n * Recipe parser.\n *\n * ## Usage\n *\n * You can either directly provide the recipe string when creating the instance\n * e.g. `const recipe = new Recipe('Add @eggs{3}')`, or create it first and then pass\n * the recipe string to the {@link Recipe.parse | parse()} method.\n *\n * Look at the [properties](#properties) to see how the recipe's properties are parsed.\n *\n * @category Classes\n *\n * @example\n * ```typescript\n * import { Recipe } from \"@tmlmt/cooklang-parser\";\n *\n * const recipeString = `\n * ---\n * title: Pancakes\n * tags: [breakfast, easy]\n * ---\n * Crack the @eggs{3} with @flour{100%g} and @milk{200%mL}\n *\n * Melt some @butter{50%g} in a #pan on medium heat.\n *\n * Cook for ~{5%minutes} on each side.\n * `\n * const recipe = new Recipe(recipeString);\n * ```\n */\nexport class Recipe {\n /**\n * The parsed recipe metadata.\n */\n metadata: Metadata = {};\n /**\n * The parsed recipe ingredients.\n */\n ingredients: Ingredient[] = [];\n /**\n * The parsed recipe sections.\n */\n sections: Section[] = [];\n /**\n * The parsed recipe cookware.\n */\n cookware: Cookware[] = [];\n /**\n * The parsed recipe timers.\n */\n timers: Timer[] = [];\n /**\n * The parsed recipe servings. Used for scaling. Parsed from one of\n * {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}\n * metadata fields.\n *\n * @see {@link Recipe.scaleBy | scaleBy()} and {@link Recipe.scaleTo | scaleTo()} methods\n */\n servings?: number;\n\n /**\n * Creates a new Recipe instance.\n * @param content - The recipe content to parse.\n */\n constructor(content?: string) {\n if (content) {\n this.parse(content);\n }\n }\n\n /**\n * Parses a recipe from a string.\n * @param content - The recipe content to parse.\n */\n parse(content: string) {\n const cleanContent = content\n .replace(metadataRegex, \"\")\n .replace(commentRegex, \"\")\n .replace(blockCommentRegex, \"\")\n .trim()\n .split(/\\r\\n?|\\n/);\n\n const { metadata, servings }: MetadataExtract = extractMetadata(content);\n this.metadata = metadata;\n this.servings = servings;\n\n let blankLineBefore = true;\n let section: Section = new Section();\n const items: Step[\"items\"] = [];\n let note: Note[\"note\"] = \"\";\n let inNote = false;\n\n for (const line of cleanContent) {\n if (line.trim().length === 0) {\n flushPendingItems(section, items);\n note = flushPendingNote(section, note);\n blankLineBefore = true;\n inNote = false;\n continue;\n }\n\n if (line.startsWith(\"=\")) {\n flushPendingItems(section, items);\n note = flushPendingNote(section, note);\n\n if (this.sections.length === 0 && section.isBlank()) {\n section.name = line.substring(1).trim();\n } else {\n /* v8 ignore else -- @preserve */\n if (!section.isBlank()) {\n this.sections.push(section);\n }\n section = new Section(line.substring(1).trim());\n }\n blankLineBefore = true;\n inNote = false;\n continue;\n }\n\n if (blankLineBefore && line.startsWith(\">\")) {\n flushPendingItems(section, items);\n note = flushPendingNote(section, note);\n note += line.substring(1).trim();\n inNote = true;\n blankLineBefore = false;\n continue;\n }\n\n if (inNote) {\n if (line.startsWith(\">\")) {\n note += \" \" + line.substring(1).trim();\n } else {\n note += \" \" + line.trim();\n }\n blankLineBefore = false;\n continue;\n }\n\n note = flushPendingNote(section, note);\n\n let cursor = 0;\n for (const match of line.matchAll(tokensRegex)) {\n const idx = match.index;\n /* v8 ignore else -- @preserve */\n if (idx > cursor) {\n items.push({ type: \"text\", value: line.slice(cursor, idx) });\n }\n\n const groups = match.groups!;\n\n if (groups.mIngredientName || groups.sIngredientName)