diff --git a/docs/examples-scaling-recipes.md b/docs/examples-scaling-recipes.md
index 50ab800..86ca9bc 100644
--- a/docs/examples-scaling-recipes.md
+++ b/docs/examples-scaling-recipes.md
@@ -28,7 +28,10 @@ const recipe = Recipe(`...`)
const scaledRecipe = recipe.scaleBy(2)
```
-All the ingredients with numerical quantities have their quantities multiplied by 2, and the metadata and `servings` value will also be multiplied by 2
+In the above example, will be multiplied by 2:
+- All the ingredients (including alternative units and alternative ingredients) with scalable numerical quantities
+- The scaling metadata and `servings` value
+- [Arbitrary scalable quantities](/guide-extensions.html#arbitrary-scalable-quantities)
## Scaling to a specific number of servings
@@ -41,4 +44,7 @@ const scaledRecipe = recipe.scaleTo(4)
// const scaledRecipe = recipe.scaleBy(2)
```
-All the ingredients with scalable numerical quantities have their quantities adjusted by a factor of 4/2 in this case, and the scaling metadata (if expressed as numbers) and `servings` property value will also be multiplied by the same factor.
+In the above example, will be adjusted by a factor of 4/2:
+- All the ingredients (including alternative units and alternative ingredients) with scalable numerical quantities have their quantities adjusted by a factor of 4/2 in this case
+- The scaling metadata and `servings` value
+- [Arbitrary scalable quantities](/guide-extensions.html#arbitrary-scalable-quantities)
diff --git a/docs/guide-extensions.md b/docs/guide-extensions.md
index c3328cb..6709a49 100644
--- a/docs/guide-extensions.md
+++ b/docs/guide-extensions.md
@@ -102,6 +102,19 @@ Also works with Cookware and Timers
- Cookware can also be quantified (without any unit, e.g. `#bowls{2}`)
- Quantities will be added similarly as ingredients if cookware is referenced, e.g. `#&bowls{2}`
+## Arbitrary scalable quantities
+
+Usage: {{name:quantity%unit}}
+
+These quantities can be added to any step or note and will be scaled but not added to the ingredients list.
+
+The `name` and `unit` are optional.
+
+Examples:
+- {{5}}
+- {{2%kcal}}
+- {{factor}}
+
## Alternative units
You can define equivalent quantities in different units for the same ingredient using the pipe `|` separator within the curly braces.
diff --git a/playground/app/components/recipe/NoteContent.vue b/playground/app/components/recipe/NoteContent.vue
new file mode 100644
index 0000000..10cecca
--- /dev/null
+++ b/playground/app/components/recipe/NoteContent.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+ {{ item.value }}
+
+
+
+
+ {{ " " }}{{ getArbitrary(item.index)!.name }}
+
+
+
+
+
+
diff --git a/playground/app/components/recipe/RecipeRender.vue b/playground/app/components/recipe/RecipeRender.vue
index 455092a..63e4e00 100644
--- a/playground/app/components/recipe/RecipeRender.vue
+++ b/playground/app/components/recipe/RecipeRender.vue
@@ -150,7 +150,8 @@ const sectionsWithStepNumbers = computed(() => {
v-else-if="item.type === 'note'"
class="note ml-4 text-gray-600 italic dark:text-gray-300"
>
- Note: {{ item.note }}
+ Note:
+
diff --git a/playground/app/components/recipe/StepContent.vue b/playground/app/components/recipe/StepContent.vue
index c598e25..a183ea4 100644
--- a/playground/app/components/recipe/StepContent.vue
+++ b/playground/app/components/recipe/StepContent.vue
@@ -2,11 +2,12 @@
import type {
Recipe,
Step,
- Item,
+ StepItem,
Timer,
IngredientAlternative,
IngredientItemQuantity,
QuantityWithPlainUnit,
+ ArbitraryScalable,
} from "cooklang-parser";
const props = defineProps<{
@@ -30,7 +31,7 @@ function toPlainEquivalents(
* Get the first (primary) alternative for an ingredient item
*/
function getPrimaryAlternative(
- item: Item & { type: "ingredient" },
+ item: StepItem & { type: "ingredient" },
): IngredientAlternative | undefined {
return item.alternatives[0];
}
@@ -39,7 +40,7 @@ function getPrimaryAlternative(
* Get the other alternatives (excluding the primary one)
*/
function getOtherAlternatives(
- item: Item & { type: "ingredient" },
+ item: StepItem & { type: "ingredient" },
): IngredientAlternative[] {
return item.alternatives.slice(1);
}
@@ -47,7 +48,7 @@ function getOtherAlternatives(
/**
* Check if an ingredient item has alternatives
*/
-function hasAlternatives(item: Item & { type: "ingredient" }): boolean {
+function hasAlternatives(item: StepItem & { type: "ingredient" }): boolean {
return item.alternatives.length > 1;
}
@@ -64,6 +65,13 @@ function getCookware(index: number) {
function getTimer(index: number): Timer | undefined {
return props.recipe.timers[index];
}
+
+/**
+ * Get the arbitrary scalable by index
+ */
+function getArbitrary(index: number): ArbitraryScalable | undefined {
+ return props.recipe.arbitraries[index];
+}
@@ -77,7 +85,9 @@ function getTimer(index: number): Timer | undefined {
{{ " " }}
@@ -123,6 +133,20 @@ function getTimer(index: number): Timer | undefined {
:timer="getTimer(item.index)!"
/>
+
+
+
+
+ {{ " " }}{{ getArbitrary(item.index)!.name }}
+
+
+
diff --git a/playground/app/pages/index.vue b/playground/app/pages/index.vue
index afdd258..39d784a 100644
--- a/playground/app/pages/index.vue
+++ b/playground/app/pages/index.vue
@@ -25,6 +25,8 @@ servings: 8
tags: [baking, vegan-option]
---
+> This recipe has an energy content of {{250%kcal}}
+
Preheat oven to ~{10%minutes}.
Mash @ripe bananas{1%=large|1.5%cup} and @&ripe bananas{2%=small|1%cup} in a #large bowl{}.
diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts
index 0cc7925..9160bdc 100644
--- a/src/classes/recipe.ts
+++ b/src/classes/recipe.ts
@@ -6,7 +6,7 @@ import type {
IngredientItemQuantity,
Timer,
Step,
- Note,
+ NoteItem,
Cookware,
MetadataExtract,
CookwareItem,
@@ -22,6 +22,9 @@ import type {
QuantityWithPlainUnit,
IngredientQuantityGroup,
IngredientQuantityAndGroup,
+ ArbitraryScalable,
+ FixedNumericValue,
+ StepItem,
} from "../types";
import { Section } from "./section";
import {
@@ -35,6 +38,7 @@ import {
floatRegex,
quantityAlternativeRegex,
inlineIngredientAlternativesRegex,
+ arbitraryScalableRegex,
} from "../regex";
import {
flushPendingItems,
@@ -117,6 +121,10 @@ export class Recipe {
* The parsed recipe timers.
*/
timers: Timer[] = [];
+ /**
+ * The parsed arbitrary quantities.
+ */
+ arbitraries: ArbitraryScalable[] = [];
/**
* The parsed recipe servings. Used for scaling. Parsed from one of
* {@link Metadata.servings}, {@link Metadata.yield} or {@link Metadata.serves}
@@ -159,16 +167,82 @@ export class Recipe {
}
}
+ /**
+ * Parses a matched arbitrary scalable quantity and adds it to the given array.
+ * @private
+ * @param regexMatchGroups - The regex match groups from arbitrary scalable regex.
+ * @param intoArray - The array to push the parsed arbitrary scalable item into.
+ */
+ private _parseArbitraryScalable(
+ regexMatchGroups: RegExpMatchArray["groups"],
+ intoArray: Array,
+ ): void {
+ if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
+ const quantityMatch = regexMatchGroups.arbitraryQuantity
+ ?.trim()
+ .match(quantityAlternativeRegex);
+ if (quantityMatch?.groups) {
+ const value = quantityMatch.groups.quantity
+ ? parseQuantityInput(quantityMatch.groups.quantity)
+ : undefined;
+ const unit = quantityMatch.groups.unit;
+ const name = regexMatchGroups.arbitraryName || undefined;
+ if (!value || (value.type === "fixed" && value.value.type === "text")) {
+ throw new InvalidQuantityFormat(
+ regexMatchGroups.arbitraryQuantity?.trim(),
+ "Arbitrary quantities must have a numerical value",
+ );
+ }
+ const arbitrary: ArbitraryScalable = {
+ quantity: value as FixedNumericValue,
+ };
+ if (name) arbitrary.name = name;
+ if (unit) arbitrary.unit = unit;
+ intoArray.push({
+ type: "arbitrary",
+ index: this.arbitraries.push(arbitrary) - 1,
+ });
+ }
+ }
+
+ /**
+ * Parses text for arbitrary scalables and returns NoteItem array.
+ * @param text - The text to parse for arbitrary scalables.
+ * @returns Array of NoteItem (text and arbitrary scalable items).
+ */
+ private _parseNoteText(text: string): NoteItem[] {
+ const noteItems: NoteItem[] = [];
+ let cursor = 0;
+ const globalRegex = new RegExp(arbitraryScalableRegex.source, "g");
+
+ for (const match of text.matchAll(globalRegex)) {
+ const idx = match.index;
+ /* v8 ignore else -- @preserve */
+ if (idx > cursor) {
+ noteItems.push({ type: "text", value: text.slice(cursor, idx) });
+ }
+
+ this._parseArbitraryScalable(match.groups, noteItems);
+ cursor = idx + match[0].length;
+ }
+
+ if (cursor < text.length) {
+ noteItems.push({ type: "text", value: text.slice(cursor) });
+ }
+
+ return noteItems;
+ }
+
private _parseQuantityRecursive(
quantityRaw: string,
): QuantityWithExtendedUnit[] {
let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
const quantities: QuantityWithExtendedUnit[] = [];
while (quantityMatch?.groups) {
- const value = quantityMatch.groups.ingredientQuantityValue
- ? parseQuantityInput(quantityMatch.groups.ingredientQuantityValue)
+ const value = quantityMatch.groups.quantity
+ ? parseQuantityInput(quantityMatch.groups.quantity)
: undefined;
- const unit = quantityMatch.groups.ingredientUnit;
+ const unit = quantityMatch.groups.unit;
if (value) {
const newQuantity: QuantityWithExtendedUnit = { quantity: value };
if (unit) {
@@ -185,10 +259,8 @@ export class Recipe {
} else {
throw new InvalidQuantityFormat(quantityRaw);
}
- quantityMatch = quantityMatch.groups.ingredientAltQuantity
- ? quantityMatch.groups.ingredientAltQuantity.match(
- quantityAlternativeRegex,
- )
+ quantityMatch = quantityMatch.groups.alternative
+ ? quantityMatch.groups.alternative.match(quantityAlternativeRegex)
: null;
}
return quantities;
@@ -954,7 +1026,7 @@ export class Recipe {
let blankLineBefore = true;
let section: Section = new Section();
const items: Step["items"] = [];
- let note: Note["note"] = "";
+ let noteText = "";
let inNote = false;
// We parse content line by line
@@ -962,7 +1034,11 @@ export class Recipe {
// A blank line triggers flushing pending stuff
if (line.trim().length === 0) {
flushPendingItems(section, items);
- note = flushPendingNote(section, note);
+ flushPendingNote(
+ section,
+ noteText ? this._parseNoteText(noteText) : [],
+ );
+ noteText = "";
blankLineBefore = true;
inNote = false;
continue;
@@ -971,7 +1047,11 @@ export class Recipe {
// New section
if (line.startsWith("=")) {
flushPendingItems(section, items);
- note = flushPendingNote(section, note);
+ flushPendingNote(
+ section,
+ noteText ? this._parseNoteText(noteText) : [],
+ );
+ noteText = "";
if (this.sections.length === 0 && section.isBlank()) {
section.name = line.replace(/^=+|=+$/g, "").trim();
@@ -990,8 +1070,11 @@ export class Recipe {
// New note
if (blankLineBefore && line.startsWith(">")) {
flushPendingItems(section, items);
- note = flushPendingNote(section, note);
- note += line.substring(1).trim();
+ flushPendingNote(
+ section,
+ noteText ? this._parseNoteText(noteText) : [],
+ );
+ noteText = line.substring(1).trim();
inNote = true;
blankLineBefore = false;
continue;
@@ -1000,14 +1083,15 @@ export class Recipe {
// Continue note
if (inNote) {
if (line.startsWith(">")) {
- note += " " + line.substring(1).trim();
+ noteText += " " + line.substring(1).trim();
} else {
- note += " " + line.trim();
+ noteText += " " + line.trim();
}
blankLineBefore = false;
continue;
}
- note = flushPendingNote(section, note);
+ flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
+ noteText = "";
// Detecting items
let cursor = 0;
@@ -1071,6 +1155,10 @@ export class Recipe {
}
items.push(newItem);
}
+ // Arbitrary scalable quantities
+ else if (groups.arbitraryQuantity) {
+ this._parseArbitraryScalable(groups, items);
+ }
// Then it's necessarily a timer which was matched
else {
const durationStr = groups.timerQuantity!.trim();
@@ -1100,7 +1188,7 @@ export class Recipe {
// End of content reached: pushing all temporarily saved elements
flushPendingItems(section, items);
- note = flushPendingNote(section, note);
+ flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
if (!section.isBlank()) {
this.sections.push(section);
}
@@ -1210,6 +1298,14 @@ export class Recipe {
scaleAlternativesBy(alternatives, factor);
}
+ // Scale Arbitraries
+ for (const arbitrary of newRecipe.arbitraries) {
+ arbitrary.quantity = multiplyQuantityValue(
+ arbitrary.quantity,
+ factor,
+ ) as FixedNumericValue;
+ }
+
newRecipe._populate_ingredient_quantities();
newRecipe.servings = Big(originalServings).times(factor).toNumber();
@@ -1289,6 +1385,7 @@ export class Recipe {
});
newRecipe.cookware = deepClone(this.cookware);
newRecipe.timers = deepClone(this.timers);
+ newRecipe.arbitraries = deepClone(this.arbitraries);
newRecipe.servings = this.servings;
return newRecipe;
}
diff --git a/src/errors.ts b/src/errors.ts
index bdf8005..8cce26d 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -82,8 +82,10 @@ export class IncompatibleUnitsError extends Error {
}
export class InvalidQuantityFormat extends Error {
- constructor(value: string) {
- super(`Invalid quantity format found in: ${value}`);
+ constructor(value: string, extra?: string) {
+ super(
+ `Invalid quantity format found in: ${value}${extra ? ` (${extra})` : ""}`,
+ );
this.name = "InvalidQuantityFormat";
}
}
diff --git a/src/index.ts b/src/index.ts
index dc94e7c..1d75b59 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -36,9 +36,12 @@ import type {
IngredientAlternative,
CookwareItem,
TimerItem,
- Item,
+ ArbitraryScalable,
+ ArbitraryScalableItem,
+ StepItem,
Step,
Note,
+ NoteItem,
Cookware,
CookwareFlag,
CategorizedIngredients,
@@ -101,9 +104,12 @@ export {
IngredientAlternative,
CookwareItem,
TimerItem,
- Item,
+ ArbitraryScalable,
+ ArbitraryScalableItem,
+ StepItem,
Step,
Note,
+ NoteItem,
Cookware,
CookwareFlag,
CategorizedIngredients,
diff --git a/src/regex.ts b/src/regex.ts
index d6bb980..be0f9a5 100644
--- a/src/regex.ts
+++ b/src/regex.ts
@@ -149,18 +149,18 @@ export const ingredientWithAlternativeRegex = createRegex()
export const inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1))
export const quantityAlternativeRegex = createRegex()
- .startNamedGroup("ingredientQuantityValue")
+ .startNamedGroup("quantity")
.notAnyOf("}|%").oneOrMore()
.endGroup().optional()
.startGroup()
.literal("%")
- .startNamedGroup("ingredientUnit")
+ .startNamedGroup("unit")
.notAnyOf("|}").oneOrMore()
.endGroup()
.endGroup().optional()
.startGroup()
.literal("|")
- .startNamedGroup("ingredientAltQuantity")
+ .startNamedGroup("alternative")
.startGroup()
.notAnyOf("}").oneOrMore()
.endGroup().zeroOrMore()
@@ -284,12 +284,37 @@ const timerRegex = createRegex()
.literal("}")
.toRegExp()
+export const arbitraryScalableRegex = createRegex()
+ .literal("{{")
+ .startGroup()
+ .startNamedGroup("arbitraryName")
+ .notAnyOf("}:%").oneOrMore()
+ .endGroup()
+ .literal(":")
+ .endGroup().optional()
+ .startNamedGroup("arbitraryQuantity")
+ .startGroup()
+ .notAnyOf("}|%").oneOrMore()
+ .endGroup().optional()
+ .startGroup()
+ .literal("%")
+ .notAnyOf("|}").oneOrMore().lazy()
+ .endGroup().optional()
+ .startGroup()
+ .literal("|")
+ .notAnyOf("}").oneOrMore().lazy()
+ .endGroup().zeroOrMore()
+ .endGroup()
+ .literal("}}")
+ .toRegExp();
+
export const tokensRegex = new RegExp(
[
ingredientWithGroupKeyRegex,
ingredientWithAlternativeRegex,
cookwareRegex,
timerRegex,
+ arbitraryScalableRegex
]
.map((r) => r.source)
.join("|"),
diff --git a/src/types.ts b/src/types.ts
index e79f7f7..33d76d6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -385,12 +385,12 @@ export interface IngredientItem {
* @category Types
*/
export interface RecipeAlternatives {
- /** Map of choices that can be made at Ingredient Item level
- * - Keys are the Ingredient Item IDs (e.g. "ingredient-item-2")
+ /** Map of choices that can be made at Ingredient StepItem level
+ * - Keys are the Ingredient StepItem IDs (e.g. "ingredient-item-2")
* - Values are arrays of IngredientAlternative objects representing the choices available for that item
*/
ingredientItems: Map;
- /** Map of choices that can be made for Grouped Ingredient Item's
+ /** Map of choices that can be made for Grouped Ingredient StepItem's
* - Keys are the Group IDs (e.g. "eggs" for `@|eggs|...`)
* - Values are arrays of IngredientAlternative objects representing the choices available for that group
*/
@@ -403,9 +403,9 @@ export interface RecipeAlternatives {
* @category Types
*/
export interface RecipeChoices {
- /** Map of choices that can be made at Ingredient Item level */
+ /** Map of choices that can be made at Ingredient StepItem level */
ingredientItems?: Map;
- /** Map of choices that can be made for Grouped Ingredient Item's */
+ /** Map of choices that can be made for Grouped Ingredient StepItem's */
ingredientGroups?: Map;
}
@@ -457,11 +457,40 @@ export interface TextItem {
value: string;
}
+/**
+ * Represents an arbitrary scalable quantity in a recipe.
+ * @category Types
+ */
+export interface ArbitraryScalable {
+ /** The name of the arbitrary scalable quantity. */
+ name?: string;
+ /** The numerical value of the arbitrary scalable quantity. */
+ quantity: FixedNumericValue;
+ /** The unit of the arbitrary scalable quantity. */
+ unit?: string;
+}
+
+/**
+ * Represents an arbitrary scalable quantity item in a recipe step.
+ * @category Types
+ */
+export interface ArbitraryScalableItem {
+ /** The type of the item. */
+ type: "arbitrary";
+ /** The index of the arbitrary scalable quantity, within the {@link Recipe.arbitraries | list of arbitrary scalable quantities} */
+ index: number;
+}
+
/**
* Represents an item in a recipe step.
* @category Types
*/
-export type Item = TextItem | IngredientItem | CookwareItem | TimerItem;
+export type StepItem =
+ | TextItem
+ | IngredientItem
+ | CookwareItem
+ | TimerItem
+ | ArbitraryScalableItem;
/**
* Represents a step in a recipe.
@@ -470,17 +499,23 @@ export type Item = TextItem | IngredientItem | CookwareItem | TimerItem;
export interface Step {
type: "step";
/** The items in the step. */
- items: Item[];
+ items: StepItem[];
}
+/**
+ * Represents an item in a note (can be text or arbitrary scalable).
+ * @category Types
+ */
+export type NoteItem = TextItem | ArbitraryScalableItem;
+
/**
* Represents a note in a recipe.
* @category Types
*/
export interface Note {
type: "note";
- /** The content of the note. */
- note: string;
+ /** The items in the note. */
+ items: NoteItem[];
}
/**
diff --git a/src/utils/parser_helpers.ts b/src/utils/parser_helpers.ts
index 4a9f70c..a03d8ea 100644
--- a/src/utils/parser_helpers.ts
+++ b/src/utils/parser_helpers.ts
@@ -6,6 +6,7 @@ import type {
TextValue,
DecimalValue,
FractionValue,
+ NoteItem,
} from "../types";
import {
metadataRegex,
@@ -14,7 +15,7 @@ import {
scalingMetaValueRegex,
} from "../regex";
import { Section as SectionObject } from "../classes/section";
-import type { Ingredient, Note, Step, Cookware } from "../types";
+import type { Ingredient, Step, Cookware } from "../types";
import { addQuantityValues } from "../quantities/mutations";
import {
CannotAddTextValueError,
@@ -22,20 +23,20 @@ import {
} from "../errors";
/**
- * Pushes a pending note to the section content if it's not empty.
+ * Pushes a pending note to the section content if it has items.
* @param section - The current section object.
- * @param note - The note content.
- * @returns An empty string if the note was pushed, otherwise the original note.
+ * @param noteItems - The note items array.
+ * @returns An empty array if the note was pushed, otherwise the original items.
*/
export function flushPendingNote(
section: SectionObject,
- note: Note["note"],
-): Note["note"] {
- if (note.length > 0) {
- section.content.push({ type: "note", note });
- return "";
+ noteItems: NoteItem[],
+): NoteItem[] {
+ if (noteItems.length > 0) {
+ section.content.push({ type: "note", items: [...noteItems] });
+ return [];
}
- return note;
+ return noteItems;
}
/**
diff --git a/test/__snapshots__/recipe_parsing.test.ts.snap b/test/__snapshots__/recipe_parsing.test.ts.snap
index e333662..62f380a 100644
--- a/test/__snapshots__/recipe_parsing.test.ts.snap
+++ b/test/__snapshots__/recipe_parsing.test.ts.snap
@@ -158,6 +158,7 @@ exports[`parse function > extracts steps correctly 1`] = `
exports[`parse function > parses complex recipes correctly 1`] = `
Recipe {
+ "arbitraries": [],
"choices": {
"ingredientGroups": Map {},
"ingredientItems": Map {},
@@ -674,7 +675,12 @@ Recipe {
"type": "step",
},
{
- "note": "Editor’s Tip: To ensure the noodles will not stick to each other, stir the water like a whirlpool when you add the pasta sheets.",
+ "items": [
+ {
+ "type": "text",
+ "value": "Editor’s Tip: To ensure the noodles will not stick to each other, stir the water like a whirlpool when you add the pasta sheets.",
+ },
+ ],
"type": "note",
},
],
@@ -1149,7 +1155,12 @@ Recipe {
"type": "step",
},
{
- "note": "Editor’s Tip: For the freshest, creamiest taste, grate your own cheese at home.",
+ "items": [
+ {
+ "type": "text",
+ "value": "Editor’s Tip: For the freshest, creamiest taste, grate your own cheese at home.",
+ },
+ ],
"type": "note",
},
],
@@ -1345,7 +1356,12 @@ exports[`parse function > parses notes correctly 1`] = `
Section {
"content": [
{
- "note": "This is a note at the beginning.",
+ "items": [
+ {
+ "type": "text",
+ "value": "This is a note at the beginning.",
+ },
+ ],
"type": "note",
},
{
@@ -1385,7 +1401,12 @@ exports[`parse function > parses notes correctly 1`] = `
"type": "step",
},
{
- "note": "This is a note in the middle which continues on the next line.",
+ "items": [
+ {
+ "type": "text",
+ "value": "This is a note in the middle which continues on the next line.",
+ },
+ ],
"type": "note",
},
{
@@ -1398,11 +1419,21 @@ exports[`parse function > parses notes correctly 1`] = `
"type": "step",
},
{
- "note": "Another note on multiple lines starting with > for readability",
+ "items": [
+ {
+ "type": "text",
+ "value": "Another note on multiple lines starting with > for readability",
+ },
+ ],
"type": "note",
},
{
- "note": "A final note.",
+ "items": [
+ {
+ "type": "text",
+ "value": "A final note.",
+ },
+ ],
"type": "note",
},
],
diff --git a/test/parser_helpers.test.ts b/test/parser_helpers.test.ts
index 4230633..4409636 100644
--- a/test/parser_helpers.test.ts
+++ b/test/parser_helpers.test.ts
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest";
import { Section as SectionObject } from "../src/classes/section";
-import type { Step, MetadataExtract, Cookware, Ingredient } from "../src/types";
+import type {
+ Step,
+ MetadataExtract,
+ Cookware,
+ Ingredient,
+ NoteItem,
+} from "../src/types";
import {
flushPendingNote,
flushPendingItems,
@@ -252,26 +258,26 @@ images: [https://static01.nyt.com/images/2021/12/28/dining/yf-baked-feta/yf-bake
describe("flushPendingNote", () => {
it("should add a note to the section if the note is not empty", () => {
const section = new SectionObject("Test Section");
- const note = "This is a test note.";
+ const note: NoteItem[] = [{ type: "text", value: "This is a test note." }];
const result = flushPendingNote(section, note);
expect(section.content).toHaveLength(1);
expect(section.content[0]).toEqual({
type: "note",
- note: "This is a test note.",
+ items: [{ type: "text", value: "This is a test note." }],
});
- expect(result).toBe("");
+ expect(result).toEqual([]);
});
- it("should not add a note if it is empty and return an empty string", () => {
+ it("should not add a note if it is empty and return an empty array", () => {
const section = new SectionObject("Test Section");
- const note = "";
+ const note: NoteItem[] = [];
const result = flushPendingNote(section, note);
expect(section.content).toHaveLength(0);
- expect(result).toBe("");
+ expect(result).toEqual([]);
});
});
diff --git a/test/recipe_parsing.test.ts b/test/recipe_parsing.test.ts
index ac096a7..8431956 100644
--- a/test/recipe_parsing.test.ts
+++ b/test/recipe_parsing.test.ts
@@ -11,7 +11,7 @@ import {
InvalidQuantityFormat,
ReferencedItemCannotBeRedefinedError,
} from "../src/errors";
-import type { Ingredient, IngredientItem, Step } from "../src/types";
+import type { Ingredient, IngredientItem, Note, Step } from "../src/types";
describe("parse function", () => {
it("parses basic metadata correctly", () => {
@@ -2120,6 +2120,76 @@ Another step.
});
});
+ describe("arbitrary scalable quantities", () => {
+ it("parses arbitrary scalable quantities correctly", () => {
+ const recipe = "{{2}} {{1.5%cup}} {{calory-factor:5}}";
+ const result = new Recipe(recipe);
+ expect(result.arbitraries).toHaveLength(3);
+ expect(result.arbitraries[0]).toEqual({
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 2 },
+ },
+ });
+ expect(result.arbitraries[1]).toEqual({
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 1.5 },
+ },
+ unit: "cup",
+ });
+ expect(result.arbitraries[2]).toEqual({
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 5 },
+ },
+ name: "calory-factor",
+ });
+ const step = result.sections[0]?.content[0] as Step;
+ expect(step.items[0]).toEqual({
+ type: "arbitrary",
+ index: 0,
+ });
+ expect(step.items[2]).toEqual({
+ type: "arbitrary",
+ index: 1,
+ });
+ expect(step.items[4]).toEqual({
+ type: "arbitrary",
+ index: 2,
+ });
+ });
+
+ it("parses notes containing arbitrary scalable quantities correctly", () => {
+ const recipe = `
+ > This is a note with an arbitrary quantity {{3%tbsp}} inside
+ `;
+ const result = new Recipe(recipe);
+ expect(result.arbitraries).toHaveLength(1);
+ expect(result.arbitraries[0]).toEqual({
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 3 },
+ },
+ unit: "tbsp",
+ });
+ const note = result.sections[0]?.content[0] as Note;
+ expect(note).toEqual({
+ type: "note",
+ items: [
+ { type: "text", value: "This is a note with an arbitrary quantity " },
+ { type: "arbitrary", index: 0 },
+ { type: "text", value: " inside" },
+ ],
+ });
+ });
+
+ it("throws an error if arbitrary scalable quantity has no numeric value", () => {
+ const recipe = "{{calory-factor}}";
+ expect(() => new Recipe(recipe)).toThrowError(InvalidQuantityFormat);
+ });
+ });
+
describe("clone", () => {
it("creates a deep clone of the recipe", () => {
const recipe = new Recipe(recipeToScaleWithAlternatives);
diff --git a/test/recipe_scaling.test.ts b/test/recipe_scaling.test.ts
index cc7ed95..aee5ba0 100644
--- a/test/recipe_scaling.test.ts
+++ b/test/recipe_scaling.test.ts
@@ -624,4 +624,23 @@ Add @|milk|milk{150%mL} or @|milk|oat milk{150%mL} for a vegan version.
},
]);
});
+
+ it("should scale arbitraries when scaling by", () => {
+ const recipe = new Recipe(`
+---
+servings: 2
+---
+Add {{sauce:100%g}} of sauce.
+ `);
+ const scaledRecipe = recipe.scaleBy(2);
+ expect(scaledRecipe.arbitraries.length).toBe(1);
+ expect(scaledRecipe.arbitraries[0]!).toEqual({
+ name: "sauce",
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 200 },
+ },
+ unit: "g",
+ });
+ });
});
diff --git a/test/section.test.ts b/test/section.test.ts
index ce61a5f..c58dfcc 100644
--- a/test/section.test.ts
+++ b/test/section.test.ts
@@ -5,7 +5,10 @@ describe("isBlank", () => {
it("should correctly check whether a section is blank", () => {
const section = new Section();
expect(section.isBlank()).toBe(true);
- section.content.push({ type: "note", note: "test" });
+ section.content.push({
+ type: "note",
+ items: [{ type: "text", value: "test" }],
+ });
expect(section.isBlank()).toBe(false);
});
});