From 3943854164ebaaa138d3396e2afec5425adab925 Mon Sep 17 00:00:00 2001
From: Thomas Lamant
Date: Thu, 29 Jan 2026 02:20:34 +0100
Subject: [PATCH 1/2] feat: full-recipe unit conversion
---
.../app/components/recipe/RecipeChoices.vue | 53 ++
playground/app/pages/index.vue | 49 +-
src/classes/recipe.ts | 206 +++++++
src/index.ts | 2 +
src/quantities/mutations.ts | 88 ++-
test/quantities_alternatives.test.ts | 5 +-
test/quantities_mutations.test.ts | 15 +
test/recipe_conversion.test.ts | 546 ++++++++++++++++++
8 files changed, 935 insertions(+), 29 deletions(-)
create mode 100644 test/recipe_conversion.test.ts
diff --git a/playground/app/components/recipe/RecipeChoices.vue b/playground/app/components/recipe/RecipeChoices.vue
index b3e9b61..7673ee9 100644
--- a/playground/app/components/recipe/RecipeChoices.vue
+++ b/playground/app/components/recipe/RecipeChoices.vue
@@ -3,6 +3,7 @@ import type {
Recipe,
RecipeChoices,
IngredientAlternative,
+ SpecificUnitSystem,
} from "cooklang-parser";
import { formatItemQuantity } from "cooklang-parser";
@@ -12,6 +13,30 @@ const props = defineProps<{
const servings = defineModel("servings", { required: true });
const choices = defineModel("choices", { required: true });
+const unitSystem = defineModel("unitSystem", {
+ required: true,
+});
+const conversionMethod = defineModel<"keep" | "replace" | "remove">(
+ "conversionMethod",
+ { required: true },
+);
+
+// Unit conversion options
+const unitSystems: { label: string; value: SpecificUnitSystem | null }[] = [
+ { label: "None", value: null },
+ { label: "Metric", value: "metric" },
+ { label: "US", value: "US" },
+ { label: "UK", value: "UK" },
+ { label: "Japan", value: "JP" },
+];
+const conversionMethods: {
+ label: string;
+ value: "keep" | "replace" | "remove";
+}[] = [
+ { label: "Keep original as equivalent", value: "keep" },
+ { label: "Replace original", value: "replace" },
+ { label: "Remove equivalents", value: "remove" },
+];
// Reset servings when recipe's base servings change
watch(
@@ -161,6 +186,34 @@ function setSelectedGrouped(groupKey: string, value: number | undefined) {
+
+
+
Convert Units
+
+
+
+
+
+
+
+
+
+
+
+
Possible Ingredient Choices
diff --git a/playground/app/pages/index.vue b/playground/app/pages/index.vue
index 4f5f714..73bb4aa 100644
--- a/playground/app/pages/index.vue
+++ b/playground/app/pages/index.vue
@@ -1,6 +1,6 @@
@@ -158,6 +167,8 @@ const scaledRecipe = computed(() => {
@@ -213,6 +224,8 @@ const scaledRecipe = computed(() => {
diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts
index 089eed0..53005d8 100644
--- a/src/classes/recipe.ts
+++ b/src/classes/recipe.ts
@@ -25,6 +25,7 @@ import type {
StepItem,
GetIngredientQuantitiesOptions,
SpecificUnitSystem,
+ Unit,
} from "../types";
import { Section } from "./section";
import {
@@ -56,9 +57,11 @@ import {
toPlainUnit,
toExtendedUnit,
flattenPlainUnitGroup,
+ convertQuantityToSystem,
applyBestUnit,
} from "../quantities/mutations";
import { resolveUnit } from "../units/definitions";
+import { isUnitCompatibleWithSystem } from "../units/compatibility";
import Big from "big.js";
import { deepClone } from "../utils/general";
import { InvalidQuantityFormat } from "../errors";
@@ -1304,6 +1307,209 @@ export class Recipe {
return newRecipe;
}
+ /**
+ * Converts all ingredient quantities in the recipe to a target unit system.
+ *
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
+ * @param method - How to handle existing quantities:
+ * - "keep": Keep all quantities, ensure target system is primary (swap if needed, or add converted)
+ * - "replace": Replace primary with target system quantity, discard old primary, keep only non-target equivalents
+ * - "remove": Only keep target system quantity, delete all equivalents
+ * @returns A new Recipe instance with converted quantities
+ *
+ * @example
+ * ```typescript
+ * // Convert a recipe to metric, keeping original units as equivalents
+ * const metricRecipe = recipe.convertTo("metric", "keep");
+ *
+ * // Convert to US units, removing all other equivalents
+ * const usRecipe = recipe.convertTo("US", "remove");
+ * ```
+ */
+ convertTo(
+ system: SpecificUnitSystem,
+ method: "keep" | "replace" | "remove",
+ ): Recipe {
+ const newRecipe = this.clone();
+
+ /**
+ * Helper to build a new primary from a converted quantity
+ */
+ function buildNewPrimary(
+ convertedQty: QuantityWithExtendedUnit,
+ oldPrimary: QuantityWithExtendedUnit,
+ remainingEquivalents: QuantityWithExtendedUnit[],
+ scalable: boolean,
+ integerProtected: boolean | undefined,
+ source: "converted" | "swapped",
+ ): IngredientItemQuantity {
+ const newUnit: Unit | undefined =
+ integerProtected && convertedQty.unit
+ ? { name: convertedQty.unit.name, integerProtected: true }
+ : convertedQty.unit;
+
+ const newPrimary: IngredientItemQuantity = {
+ quantity: convertedQty.quantity,
+ unit: newUnit,
+ scalable,
+ };
+
+ if (method === "remove") {
+ return newPrimary;
+ } else if (method === "replace") {
+ if (remainingEquivalents.length > 0) {
+ // Keep remaining equivalents
+ newPrimary.equivalents = remainingEquivalents;
+ // An equivalent was converted and replaced, we still want to keep the oldPrimary
+ if (source === "converted") newPrimary.equivalents.push(oldPrimary);
+ }
+ } else {
+ // method === "keep": include old primary + remaining equivalents
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
+ }
+
+ return newPrimary;
+ }
+
+ /**
+ * Convert a single IngredientItemQuantity to the target system.
+ */
+ function convertItemQuantity(
+ itemQuantity: IngredientItemQuantity,
+ ): IngredientItemQuantity {
+ const primaryUnit = resolveUnit(itemQuantity.unit?.name);
+ const equivalents = itemQuantity.equivalents ?? [];
+ const oldPrimary: QuantityWithExtendedUnit = {
+ quantity: itemQuantity.quantity,
+ unit: itemQuantity.unit,
+ };
+
+ // Check if primary is already in target system
+ if (
+ primaryUnit.type !== "other" &&
+ isUnitCompatibleWithSystem(primaryUnit, system)
+ ) {
+ // Primary is already in target system
+ if (method === "remove") {
+ return { ...itemQuantity, equivalents: undefined };
+ }
+ return itemQuantity;
+ }
+
+ // Look for an equivalent in the target system
+ const targetEquivIndex = equivalents.findIndex((eq) => {
+ const eqUnit = resolveUnit(eq.unit?.name);
+ return (
+ eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system)
+ );
+ });
+
+ if (targetEquivIndex !== -1) {
+ // Found an equivalent in target system - swap with primary
+ const targetEquiv = equivalents[targetEquivIndex]!;
+ const remainingEquivalents = equivalents.filter(
+ (_, i) => i !== targetEquivIndex,
+ );
+ return buildNewPrimary(
+ targetEquiv,
+ oldPrimary,
+ remainingEquivalents,
+ itemQuantity.scalable,
+ targetEquiv.unit?.integerProtected,
+ "swapped",
+ );
+ }
+
+ // No equivalent in target system - try to convert from primary
+ const converted = convertQuantityToSystem(oldPrimary, system);
+
+ if (converted && converted.unit) {
+ return buildNewPrimary(
+ converted,
+ oldPrimary,
+ equivalents,
+ itemQuantity.scalable,
+ itemQuantity.unit?.integerProtected,
+ "swapped",
+ );
+ }
+
+ // Primary cannot be converted - try to convert from equivalents
+ for (let i = 0; i < equivalents.length; i++) {
+ const equiv = equivalents[i]!;
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
+
+ // v8 ignore else -- @preserve
+ if (convertedEquiv && convertedEquiv.unit) {
+ const remainingEquivalents =
+ method === "keep"
+ ? equivalents
+ : equivalents.filter((_, idx) => idx !== i);
+ return buildNewPrimary(
+ convertedEquiv,
+ oldPrimary,
+ remainingEquivalents,
+ itemQuantity.scalable,
+ equiv.unit?.integerProtected,
+ "converted",
+ );
+ }
+ }
+
+ // Cannot convert - return as-is (or with cleared equivalents for "remove")
+ // v8 ignore next -- @preserve
+ if (method === "remove") {
+ return { ...itemQuantity, equivalents: undefined };
+ } else {
+ return itemQuantity;
+ }
+ }
+
+ /**
+ * Convert all alternatives in a list
+ */
+ function convertAlternatives(alternatives: IngredientAlternative[]) {
+ for (const alternative of alternatives) {
+ // v8 ignore else -- @preserve
+ if (alternative.itemQuantity) {
+ alternative.itemQuantity = convertItemQuantity(
+ alternative.itemQuantity,
+ );
+ }
+ }
+ }
+
+ // Convert IngredientItems in sections
+ for (const section of newRecipe.sections) {
+ for (const step of section.content.filter(
+ (item) => item.type === "step",
+ )) {
+ for (const item of step.items.filter(
+ (item) => item.type === "ingredient",
+ )) {
+ convertAlternatives(item.alternatives);
+ }
+ }
+ }
+
+ // Convert Choices
+ for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
+ convertAlternatives(alternatives);
+ }
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
+ convertAlternatives(alternatives);
+ }
+
+ // Re-aggregate ingredient quantities
+ newRecipe._populate_ingredient_quantities();
+
+ // Setting the unit system in 'keep' mode will convert all equivalents to that system
+ // which will lead to duplicates
+ if (method !== "keep") Recipe.unitSystems.set(newRecipe, system);
+
+ return newRecipe;
+ }
+
/**
* Gets the number of servings for the recipe.
* @private
diff --git a/src/index.ts b/src/index.ts
index c825460..7862d49 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -39,6 +39,7 @@ import {
isSimpleGroup,
hasAlternatives,
} from "./utils/type_guards";
+import { convertQuantityToSystem } from "./quantities/mutations";
export {
isAlternativeSelected,
@@ -54,6 +55,7 @@ export {
isAndGroup,
isSimpleGroup,
hasAlternatives,
+ convertQuantityToSystem,
};
// Types
diff --git a/src/quantities/mutations.ts b/src/quantities/mutations.ts
index fde97f6..34ec520 100644
--- a/src/quantities/mutations.ts
+++ b/src/quantities/mutations.ts
@@ -344,6 +344,80 @@ function addAndFindBestUnit(
};
}
+/**
+ * Converts a quantity to the best unit in a target system.
+ * Returns the converted quantity, or undefined if the unit type is "other" or not convertible.
+ *
+ * @category Helpers
+ *
+ * @param quantity - The quantity to convert
+ * @param system - The target unit system
+ * @returns The converted quantity, or undefined if conversion not possible
+ */
+
+export function convertQuantityToSystem(
+ quantity: QuantityWithPlainUnit,
+ system: SpecificUnitSystem,
+): QuantityWithPlainUnit | undefined;
+export function convertQuantityToSystem(
+ quantity: QuantityWithExtendedUnit,
+ system: SpecificUnitSystem,
+): QuantityWithExtendedUnit | undefined;
+export function convertQuantityToSystem(
+ quantity: QuantityWithPlainUnit | QuantityWithExtendedUnit,
+ system: SpecificUnitSystem,
+): QuantityWithPlainUnit | QuantityWithExtendedUnit | undefined {
+ const unitDef = resolveUnit(
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name,
+ );
+
+ // Cannot convert "other" type units or units without toBase
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
+ return undefined;
+ }
+
+ const avgValue = getAverageValue(quantity.quantity);
+ if (typeof avgValue !== "number") {
+ return undefined;
+ }
+
+ const toBase = getToBase(unitDef, system);
+ const valueInBase = avgValue * toBase;
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
+ valueInBase,
+ unitDef.type,
+ system,
+ [unitDef],
+ );
+
+ // Format the value (uses fractions if unit supports them)
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
+
+ // Handle ranges
+ if (quantity.quantity.type === "range") {
+ const bestToBase = getToBase(bestUnit, system);
+
+ const minValue =
+ (getNumericValue(quantity.quantity.min) * toBase) / bestToBase;
+ const maxValue =
+ (getNumericValue(quantity.quantity.max) * toBase) / bestToBase;
+
+ return {
+ quantity: {
+ type: "range",
+ min: formatOutputValue(minValue, bestUnit),
+ max: formatOutputValue(maxValue, bestUnit),
+ },
+ unit: { name: bestUnit.name },
+ };
+ }
+
+ return {
+ quantity: { type: "fixed", value: formattedValue },
+ unit: { name: bestUnit.name },
+ };
+}
+
export function toPlainUnit(
quantity:
| QuantityWithExtendedUnit
@@ -556,10 +630,8 @@ export function applyBestUnit(
return q;
}
- const avgValue = getAverageValue(q.quantity);
- if (typeof avgValue !== "number") {
- return q;
- }
+ // string is filtered out in the above if
+ const avgValue = getAverageValue(q.quantity) as number;
// Determine effective system: use provided system, or infer from unit
const effectiveSystem: SpecificUnitSystem =
@@ -581,7 +653,7 @@ export function applyBestUnit(
);
// Get canonical name of the original unit for comparison
- const originalCanonicalName = normalizeUnit(q.unit.name)?.name ?? q.unit.name;
+ const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
// If same unit (by canonical name match), no change needed - preserve original unit name
if (bestUnit.name === originalCanonicalName) {
@@ -594,10 +666,8 @@ export function applyBestUnit(
// Handle ranges: scale to the best unit
if (q.quantity.type === "range") {
const bestToBase = getToBase(bestUnit, effectiveSystem);
- const minValue =
- (getNumericValue(q.quantity.min) * toBase) / bestToBase;
- const maxValue =
- (getNumericValue(q.quantity.max) * toBase) / bestToBase;
+ const minValue = (getNumericValue(q.quantity.min) * toBase) / bestToBase;
+ const maxValue = (getNumericValue(q.quantity.max) * toBase) / bestToBase;
return {
quantity: {
diff --git a/test/quantities_alternatives.test.ts b/test/quantities_alternatives.test.ts
index e23b9a8..938ac60 100644
--- a/test/quantities_alternatives.test.ts
+++ b/test/quantities_alternatives.test.ts
@@ -319,10 +319,10 @@ describe("addEquivalentsAndSimplify", () => {
});
it("correctly take integer-protected units into account", () => {
const or1: FlatOrGroup = {
- or: [q(2, "large", true), q(1.5, "cup")],
+ or: [q(2, "large", true), q(1.5, "cup"), q(355, "ml")],
};
const or2: FlatOrGroup = {
- or: [q(2, "small"), q(1, "cup")],
+ or: [q(2, "small"), q(1, "cup"), q(237, "mL")],
};
// 1.5 + 1 = 2.5 cups → 5/2 as fraction (cup has fractions enabled)
expect(addEquivalentsAndSimplify([or1, or2])).toEqual({
@@ -335,6 +335,7 @@ describe("addEquivalentsAndSimplify", () => {
},
unit: "cup",
},
+ qPlain(592, "ml"),
],
});
});
diff --git a/test/quantities_mutations.test.ts b/test/quantities_mutations.test.ts
index 83940de..20971cc 100644
--- a/test/quantities_mutations.test.ts
+++ b/test/quantities_mutations.test.ts
@@ -6,6 +6,7 @@ import {
addQuantities,
getDefaultQuantityValue,
normalizeAllUnits,
+ convertQuantityToSystem,
toExtendedUnit,
flattenPlainUnitGroup,
applyBestUnit,
@@ -857,6 +858,20 @@ describe("getDefaultQuantityValue + addQuantities", () => {
});
});
+describe("convertQuantityToSystem", () => {
+ it("should convert a quantity with plain units", () => {
+ const input: QuantityWithPlainUnit = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } },
+ unit: "cup",
+ };
+ const result = convertQuantityToSystem(input, "metric");
+ expect(result).toEqual({
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 237 } },
+ unit: { name: "ml" },
+ });
+ });
+});
+
describe("toExtendedUnit", () => {
it("should convert a simple QuantityWithPlainUnit", () => {
const input: QuantityWithPlainUnit = {
diff --git a/test/recipe_conversion.test.ts b/test/recipe_conversion.test.ts
new file mode 100644
index 0000000..b74928a
--- /dev/null
+++ b/test/recipe_conversion.test.ts
@@ -0,0 +1,546 @@
+import { describe, it, expect } from "vitest";
+import { Recipe } from "../src/classes/recipe";
+import type {
+ IngredientItemQuantity,
+ IngredientQuantityAndGroup,
+} from "../src/types";
+
+describe("Recipe.convertTo", () => {
+ // Helper to get the first ingredient item quantity from a recipe
+ function getFirstItemQuantity(
+ recipe: Recipe,
+ ): IngredientItemQuantity | undefined {
+ const step = recipe.sections[0]?.content.find((c) => c.type === "step");
+ if (!step || step.type !== "step") return undefined;
+ const item = step.items.find((i) => i.type === "ingredient");
+ if (!item || item.type !== "ingredient") return undefined;
+ return item.alternatives[0]?.itemQuantity;
+ }
+
+ describe("when primary is already in target system", () => {
+ it("keeps primary unchanged with 'keep' method", () => {
+ const recipe = new Recipe("Add @flour{500%g}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } },
+ unit: { name: "g" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("primary replaced with 'replace' method", () => {
+ const recipe = new Recipe("Add @flour{500%g}");
+ const converted = recipe.convertTo("US", "replace");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 1.1 },
+ },
+ unit: { name: "lb" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("removes equivalents with 'remove' method", () => {
+ const recipe = new Recipe("Add @flour{500%g|1.1%lb}");
+ const converted = recipe.convertTo("metric", "remove");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 500 } },
+ unit: { name: "g" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("preserves integerProtected flag when converting", () => {
+ // =bag is an integer-protected unit
+ const recipe = new Recipe("Add @chips{200%=g}");
+ const converted = recipe.convertTo("US", "replace");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 7.05 } },
+ unit: { name: "oz", integerProtected: true },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+ });
+
+ describe("when equivalent exists in target system", () => {
+ it("swaps equivalent to primary with 'keep' method", () => {
+ // Use fl-oz which is NOT compatible with metric (only US/UK)
+ const recipe = new Recipe("Add @butter{2%fl-oz|56%g}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 56 } },
+ unit: { name: "g" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } },
+ unit: { name: "fl-oz" },
+ },
+ ],
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("swaps equivalent to primary with 'replace' method and keeps non-target equivalents", () => {
+ // fl-oz (primary) is only US/UK compatible, g (equiv) is metric, tbsp is ambiguous
+ // For 'replace':
+ // - fl-oz was the old primary (discarded)
+ // - g became the new primary
+ // - tbsp remains in equivalents (it was not the old primary)
+ const recipe = new Recipe("Add @butter{2%fl-oz|56%g|4%tbsp}");
+ const converted = recipe.convertTo("metric", "replace");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 56 } },
+ unit: { name: "g" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 4 } },
+ unit: { name: "tbsp" },
+ },
+ ],
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("swaps equivalent to primary with 'remove' method and clears equivalents", () => {
+ const recipe = new Recipe("Add @butter{2%fl-oz|56%g}");
+ const converted = recipe.convertTo("metric", "remove");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 56 } },
+ unit: { name: "g" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+
+ // Also the other way around
+ const recipe2 = new Recipe("Add @butter{56%g|2%fl-oz}");
+ const converted2 = recipe2.convertTo("metric", "remove");
+
+ const itemQty2 = getFirstItemQuantity(converted2);
+ const expected2: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 56 } },
+ unit: { name: "g" },
+ scalable: true,
+ };
+ expect(itemQty2).toEqual(expected2);
+ });
+ });
+
+ describe("when conversion is needed (no equivalent in target system)", () => {
+ it("converts primary to target system with 'keep' method", () => {
+ const recipe = new Recipe("Add @flour{2%cup}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 473 } },
+ unit: { name: "ml" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } },
+ unit: { name: "cup" },
+ },
+ ],
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("converts primary to target system with 'replace' method", () => {
+ const recipe = new Recipe("Add @flour{2%cup}");
+ const converted = recipe.convertTo("metric", "replace");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 473 } },
+ unit: { name: "ml" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+
+ const recipe2 = new Recipe("Add @flour{1%bag|2%cup}");
+ const converted2 = recipe2.convertTo("metric", "replace");
+
+ const itemQty2 = getFirstItemQuantity(converted2);
+ const expected2: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 473 } },
+ unit: { name: "ml" },
+ scalable: true,
+ };
+ expect(itemQty2).toEqual(expected2);
+ });
+
+ it("converts with 'replace' and filters out target-compatible equivalents", () => {
+ // cup (primary, US/UK/metric compatible) with an existing ml equivalent
+ // When converting to metric with 'replace':
+ // - cup was the old primary (discarded)
+ // - ml is metric-compatible (filtered out)
+ // Result: only converted primary, no equivalents
+ const recipe = new Recipe("Add @flour{2%cup|473%ml}");
+ const converted = recipe.convertTo("metric", "replace");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 473 } },
+ unit: { name: "ml" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("converts primary to target system with 'remove' method and clears equivalents", () => {
+ const recipe = new Recipe("Add @flour{2%cup|1%bag}");
+ const converted = recipe.convertTo("metric", "remove");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 473 } },
+ unit: { name: "ml" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("converts from metric to US", () => {
+ const recipe = new Recipe("Add @water{500%ml}");
+ const converted = recipe.convertTo("US", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 2.11 },
+ },
+ unit: { name: "cup" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 500 },
+ },
+ unit: { name: "ml" },
+ },
+ ],
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("handles correctly integerProtected units when converting", () => {
+ // =bag is an integer-protected unit
+ const recipe = new Recipe("Add @bananas{1%=large|1.5%cup}");
+ const converted = recipe.convertTo("metric", "keep");
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 355 } },
+ unit: { name: "ml" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } },
+ unit: { name: "large", integerProtected: true },
+ },
+ {
+ quantity: {
+ type: "fixed",
+ value: { type: "decimal", decimal: 1.5 },
+ },
+ unit: { name: "cup" },
+ },
+ ],
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("keeps all added equivalents with 'keep' method", () => {
+ const recipe = new Recipe(
+ "Add @bananas{1%=large|1.5%cup} and @&bananas{1%=small|1%cup}",
+ );
+ const converted = recipe.convertTo("metric", "keep");
+ const ingQty = converted.ingredients[0]!.quantities![0];
+ const expected: IngredientQuantityAndGroup = {
+ and: [
+ {
+ quantity: {
+ type: "fixed",
+ value: {
+ type: "decimal",
+ decimal: 1,
+ },
+ },
+ unit: "large",
+ },
+ {
+ quantity: {
+ type: "fixed",
+ value: {
+ type: "decimal",
+ decimal: 1,
+ },
+ },
+ unit: "small",
+ },
+ ],
+ equivalents: [
+ {
+ quantity: {
+ type: "fixed",
+ value: {
+ type: "decimal",
+ decimal: 592,
+ },
+ },
+ unit: "ml",
+ },
+ {
+ quantity: {
+ type: "fixed",
+ value: {
+ type: "fraction",
+ num: 5,
+ den: 2,
+ },
+ },
+ unit: "cup",
+ },
+ ],
+ };
+ expect(ingQty).toEqual(expected);
+ });
+
+ it("getIngredientQuantities returns same result as ingredients after conversion", () => {
+ const recipe = new Recipe(
+ "Add @bananas{1%=large|1.5%cup} and @&bananas{1%=small|1%cup}",
+ );
+ const converted = recipe.convertTo("metric", "keep");
+
+ // Both should have the same quantities
+ const directQty = converted.ingredients[0]!.quantities![0];
+ const computedQty =
+ converted.getIngredientQuantities()[0]!.quantities![0];
+
+ expect(computedQty).toEqual(directQty);
+ });
+ });
+
+ describe("with unconvertible units", () => {
+ it("keeps unknown units unchanged with 'keep' method", () => {
+ const recipe = new Recipe("Add @eggs{3%large}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } },
+ unit: { name: "large" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("keeps unknown units but clears equivalents with 'remove' method", () => {
+ const recipe = new Recipe("Add @eggs{3%large|180%g}");
+ const converted = recipe.convertTo("metric", "remove");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 180 } },
+ unit: { name: "g" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("keeps unknown units and clears equivalents when no conversion possible with 'remove'", () => {
+ // Unknown unit with no metric equivalent
+ const recipe = new Recipe("Add @eggs{3%large}");
+ const converted = recipe.convertTo("metric", "remove");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } },
+ unit: { name: "large" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("keeps quantity without unit unchanged", () => {
+ const recipe = new Recipe("Add @eggs{3}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 3 } },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+
+ it("handles text quantity values", () => {
+ const recipe = new Recipe("Add @salt{one%cup}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "text", text: "one" } },
+ unit: { name: "cup" },
+ scalable: true,
+ };
+ expect(itemQty).toEqual(expected);
+ });
+ });
+
+ describe("updates recipe unit system", () => {
+ it("sets unitSystem to the target system", () => {
+ const recipe = new Recipe("Add @flour{2%cup}");
+ expect(recipe.unitSystem).toBeUndefined();
+
+ const converted = recipe.convertTo("metric", "replace");
+ expect(converted.unitSystem).toBe("metric");
+ });
+ });
+
+ describe("aggregates ingredients correctly", () => {
+ it("re-aggregates ingredient quantities after conversion", () => {
+ const recipe = new Recipe("Add @flour{100%g} and @&flour{200%g}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ // Should have one aggregated ingredient
+ expect(converted.ingredients).toHaveLength(1);
+ expect(converted.ingredients[0]?.name).toBe("flour");
+
+ const qty = converted.ingredients[0]?.quantities?.[0];
+ // Aggregated quantities have a simpler structure (unit as string, no scalable)
+ expect(qty).toEqual({
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 300 } },
+ unit: "g",
+ });
+ });
+ });
+
+ describe("handles ingredient alternatives", () => {
+ it("converts inline alternatives", () => {
+ const recipe = new Recipe("Add @flour{2%cup}|@rice flour{2%cup}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ // Both alternatives should be converted
+ const step = converted.sections[0]?.content.find(
+ (c) => c.type === "step",
+ );
+ if (step?.type !== "step") throw new Error("Expected step");
+ const item = step.items.find((i) => i.type === "ingredient");
+ if (item?.type !== "ingredient") throw new Error("Expected ingredient");
+
+ const expectedItemQty: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 473 } },
+ unit: { name: "ml" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } },
+ unit: { name: "cup" },
+ },
+ ],
+ };
+ // Check both alternatives are converted
+ for (const alt of item.alternatives) {
+ expect(alt.itemQuantity).toEqual(expectedItemQty);
+ }
+ });
+ });
+
+ describe("handles ranges", () => {
+ it("converts range quantities", () => {
+ const recipe = new Recipe("Add @flour{1-2%cup}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ const itemQty = getFirstItemQuantity(converted);
+ const expected: IngredientItemQuantity = {
+ quantity: {
+ type: "range",
+ min: { type: "decimal", decimal: 237 },
+ max: { type: "decimal", decimal: 473 },
+ },
+ unit: { name: "ml" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: {
+ type: "range",
+ min: { type: "decimal", decimal: 1 },
+ max: { type: "decimal", decimal: 2 },
+ },
+ unit: { name: "cup" },
+ },
+ ],
+ };
+ expect(itemQty).toEqual(expected);
+ });
+ });
+
+ describe("does not mutate original recipe", () => {
+ it("returns a new recipe instance", () => {
+ const recipe = new Recipe("Add @flour{2%cup}");
+ const converted = recipe.convertTo("metric", "keep");
+
+ expect(converted).not.toBe(recipe);
+
+ // Original should still have cup
+ const originalQty = getFirstItemQuantity(recipe);
+ const expectedOriginal: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 2 } },
+ unit: { name: "cup" },
+ scalable: true,
+ };
+ expect(originalQty).toEqual(expectedOriginal);
+ });
+ });
+
+ describe("handles grouped alternatives (choices)", () => {
+ it("converts grouped alternatives", () => {
+ const recipe = new Recipe(`
+Add @|dairy|milk{1%cup} or @|dairy|cream{1%cup}
+`);
+ const converted = recipe.convertTo("metric", "keep");
+
+ // Check choices are converted
+ const groupAlts = converted.choices.ingredientGroups.get("dairy");
+ expect(groupAlts).toBeDefined();
+
+ const expectedItemQty: IngredientItemQuantity = {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 237 } },
+ unit: { name: "ml" },
+ scalable: true,
+ equivalents: [
+ {
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 1 } },
+ unit: { name: "cup" },
+ },
+ ],
+ };
+ for (const alt of groupAlts ?? []) {
+ expect(alt.itemQuantity).toEqual(expectedItemQty);
+ }
+ });
+ });
+});
From 4da064df8fc02364d17520a6d618f9a62c59a9e0 Mon Sep 17 00:00:00 2001
From: Thomas Lamant
Date: Fri, 30 Jan 2026 12:44:00 +0100
Subject: [PATCH 2/2] docs: add full-recipe conversion guide and spin out units
definitions
---
docs/.vitepress/config.mts | 12 ++-
docs/guide-extensions.md | 2 +-
docs/guide-unit-conversion.md | 88 +++++++++++++++++
docs/{guide-units.md => reference-units.md} | 103 ++------------------
src/classes/recipe.ts | 4 +-
src/types.ts | 2 +-
6 files changed, 112 insertions(+), 99 deletions(-)
create mode 100644 docs/guide-unit-conversion.md
rename docs/{guide-units.md => reference-units.md} (52%)
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 4a61654..8755eec 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -37,14 +37,22 @@ export default defineConfig({
{ text: "Quick start", link: "/api/#quick-start" },
{ text: "Cooklang specs", link: "/guide-cooklang-specs" },
{ text: "Extensions", link: "/guide-extensions" },
- { text: "Units and conversions", link: "/guide-units" },
+ { text: "Unit conversion", link: "/guide-unit-conversion" },
],
collapsed: true
},
{
text: "API",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- items: typedocSidebar,
+ items: [
+ {
+ text: "Reference",
+ items: [
+ {
+ text: "Units definition", link: "/reference-units"
+ }
+ ],
+ }, ...typedocSidebar],
collapsed: true
},
{
diff --git a/docs/guide-extensions.md b/docs/guide-extensions.md
index c0a7186..dff3dba 100644
--- a/docs/guide-extensions.md
+++ b/docs/guide-extensions.md
@@ -18,7 +18,7 @@ One of more prefixes can be added before the ingredient name (`@name{
Use case: `Add @water{1%L} first, and than again @&water{100%mL}` will create one "water" ingredient with a quantity of 1.1L
-- The quantities will be added to the ingredient having the same name existing in the `ingredients` list, according to the rules explained in the [units guide](/guide-units)
+- The quantities will be added to the ingredient having the same name existing in the `ingredients` list, according to the rules explained in the [unit conversion guide](/guide-unit-conversion)
- The quantity for this specific instance will be saved as part of the item
- If the referenced ingredient is not found or if the quantities cannot be added, a new ingredient will be created
diff --git a/docs/guide-unit-conversion.md b/docs/guide-unit-conversion.md
new file mode 100644
index 0000000..d80cdbe
--- /dev/null
+++ b/docs/guide-unit-conversion.md
@@ -0,0 +1,88 @@
+---
+outline: deep
+---
+
+# Guide: unit conversion
+
+For units definition, see [Units Reference](/reference-units).
+
+## Automatic unit selection and conversion
+
+When quantities are added together (e.g., from [referenced ingredients](/guide-extensions.html#reference-to-an-existing-ingredient)), the parser selects the most appropriate unit for the result. This does **not** apply to individual quantities—`@flour{500%g}` will always parse as `500 g`.
+
+### Specifying a unit system
+
+You can specify a unit system in your recipe metadata to control how ambiguous units are resolved:
+
+```cooklang
+---
+unit system: UK
+---
+Add @water{1%cup} and some more @&water{1%fl-oz}
+```
+
+Valid values (case insensitive) are: `metric`, `US`, `UK`, `JP` (see [Unit Reference Table](#unit-reference-table) above)
+
+When no `unit system` is specified:
+- Units with a **metric** definition (like `tsp`, `tbsp`) default to metric
+- Units without a metric definition (like `cup`, `pint`) default to US
+
+### System selection
+
+The target system depends on the input units and recipe metadata:
+
+1. **Recipe has `unit system` metadata** → Use the specified system. Example with `unit system: UK`: `1%cup` + `1%fl-oz` becomes `11%fl-oz`
+
+Otherwise:
+
+2. **One unit is metric** → Convert to metric. Example: `1%lb` + `500%g` becomes `954%g`
+
+3. **Both units are ambiguous and US-compatible** → Use US system. Example: `1%cup` + `1%fl-oz` becomes `9%fl-oz`
+
+4. **Different non-metric systems** → Convert to metric. Example: `1%go` + `1%cup` becomes `417%ml`
+
+5. **Incompatible units** (e.g., text values, or volume and mass) → Quantities won't be added and will be kept separate.
+
+### Unit selection algorithm
+
+Once the system is determined, the best unit is selected based on:
+
+1. **Candidates units**:
+ - Units that belong to that system are considered potential candidates for best unit. The JP system also includes all the metric units. Certain units are disabled as not commonly used, by setting `isBestUnit` to false (default: true)
+ - The units of the input quantities are restored into that list, as they are actually already used in the recipe.
+
+2. **Valid range**: A value is considered "in range" for a unit if:
+ - It's between 1 and the unit's `maxValue` (default: 999), OR
+ - It's less than 1 but can be approximated as a fraction (for units with fractions enabled)
+
+::: info Example: fraction-aware selection
+With US units, a value of 1.7 ml (~0.345 tsp) will select `tsp` because:
+- 0.345 ≈ 1/3, which is a valid fraction (denominator 3 is allowed)
+- `tsp` has `fractions.enabled: true`
+- Therefore 0.345 tsp is considered "in range" and is the smallest valid option
+:::
+
+3. **Selection priority** (among in-range candidates):
+ - Smallest integer in the input unit family. Examples:
+ - `1 cup + 1 cup` -> `2 cup` and not 1 pint
+ - `0.5 pint + 0.5 pint` -> `1 pint` and not 2 cup
+ - `2 cup + 1 pint` -> `2 pint` and not 4 cup
+ - Smallest integers in any compatible family
+ - Smallest non-integer value in range
+
+4. **Fallback**: If no candidate is in range, the unit closest to the valid range is selected. This is in particular used for potential edge cases with values above 999 liters or 999 gallons.
+
+The per-unit configuration is detailed in the [Units Reference](/reference-units#units-configuration)
+
+## Full-recipe unit conversion
+
+It is also possible to convert an entire recipe into a specific unit system, using the [`convertTo()`](/api/classes/Recipe.html#convertto) method of the Recipe instance which returns a new Recipe with the specific conversion applied.
+
+```typescript
+function convertTo(unit: SpecificUnitSystem, method: method: "keep" | "replace" | "remove"): Recipe
+```
+
+There are three modes for full-recipe unit conversion:
+- `keep` will keep existing equivalents, and add the equivalent in the specified system
+- `replace` will replace whichever equivalent was used for conversion, and keep the other equivalents
+- `remove` will only leave the equivalent in the specified system and remove all others
diff --git a/docs/guide-units.md b/docs/reference-units.md
similarity index 52%
rename from docs/guide-units.md
rename to docs/reference-units.md
index 12c3fa4..6837a05 100644
--- a/docs/guide-units.md
+++ b/docs/reference-units.md
@@ -2,14 +2,11 @@
outline: deep
---
-# Guide: units and conversion
+# Reference: Units
-When adding quantities of [referenced ingredients](/guide-extensions.html#reference-to-an-existing-ingredient) together for the ingredients list (i.e the [ingredients](/api/classes/Recipe.html#ingredients) properties of a `Recipe`), the parser tries its best to add apples to apples.
+## Units definitions
-
-## Unit reference table
-
-The following table shows all recognized units:
+The following table shows all recognized units. Some units like `cup`, `tsp`, and `tbsp` have different sizes depending on the measurement system. These are marked as **ambiguous** and have system-specific conversion factors in the `toBaseBySystem` column.
### Mass
@@ -20,7 +17,9 @@ The following table shows all recognized units:
| oz | mass | ambiguous | ounce, ounces | 28.3495 | US: 28.3495, UK: 28.3495 |
| lb | mass | ambiguous | pound, pounds | 453.592 | US: 453.592, UK: 453.592 |
-### Volume (Metric)
+### Volume
+
+#### Metric
| Name | Type | System | Aliases | To Base (default) | To Base by System |
| ---- | ------ | ------ | ---------------------------------------------------- | ----------------- | ----------------- |
@@ -29,20 +28,20 @@ The following table shows all recognized units:
| dl | volume | metric | deciliter, deciliters, decilitre, decilitres | 100 | |
| l | volume | metric | liter, liters, litre, litres | 1000 | |
-### Volume (JP)
+#### JP
| Name | Type | System | Aliases | To Base (default) | To Base by System |
| ---- | ------ | ------ | -------------------- | ----------------- | ----------------- |
| go | volume | JP | gou, goo, 合, rice cup | 180 | |
-### Volume (Ambiguous: metric/US/UK)
+#### Ambiguous: metric/US/UK
| Name | Type | System | Aliases | To Base (default) | To Base by System |
| ---- | ------ | --------- | ---------------------- | ----------------- | ----------------------------------- |
| tsp | volume | ambiguous | teaspoon, teaspoons | 5 (metric) | metric: 5, US: 4.929, UK: 5.919 |
| tbsp | volume | ambiguous | tablespoon, tablespoons | 15 (metric) | metric: 15, US: 14.787, UK: 17.758 |
-### Volume (Ambiguous: US/UK only)
+#### Ambiguous: US/UK only
| Name | Type | System | Aliases | To Base (default) | To Base by System |
| ------ | ------ | --------- | ------------------------- | ----------------- | ------------------------ |
@@ -58,89 +57,7 @@ The following table shows all recognized units:
| ----- | ----- | ------ | ---------- | ----------------- | ----------------- |
| piece | count | metric | pieces, pc | 1 | |
-## Ambiguous units
-
-Some units like `cup`, `tsp`, and `tbsp` have different sizes depending on the measurement system. These are marked as **ambiguous** and have system-specific conversion factors in the `toBaseBySystem` column.
-
-## Specifying a unit system
-
-You can specify a unit system in your recipe metadata to control how ambiguous units are resolved:
-
-```cooklang
----
-unit system: UK
----
-Add @water{1%cup} and some more @&water{1%fl-oz}
-```
-
-Valid values (case insensitive) are: `metric`, `US`, `UK`, `JP` (see [Unit Reference Table](#unit-reference-table) above)
-
-When no `unit system` is specified:
-- Units with a **metric** definition (like `tsp`, `tbsp`) default to metric
-- Units without a metric definition (like `cup`, `pint`) default to US
-
-## Adding quantities
-
-When quantities are added together (e.g., from [referenced ingredients](/guide-extensions.html#reference-to-an-existing-ingredient)), the parser selects the most appropriate unit for the result. This does **not** apply to individual quantities—`@flour{500%g}` will always parse as `500 g`.
-
-### System selection
-
-The target system depends on the input units and recipe metadata:
-
-1. **Recipe has `unit system` metadata** → Use the specified system. Example with `unit system: UK`: `1%cup` + `1%fl-oz` becomes `11%fl-oz`
-
-Otherwise:
-
-2. **One unit is metric** → Convert to metric. Example: `1%lb` + `500%g` becomes `954%g`
-
-3. **Both units are ambiguous and US-compatible** → Use US system. Example: `1%cup` + `1%fl-oz` becomes `9%fl-oz`
-
-4. **Different non-metric systems** → Convert to metric. Example: `1%go` + `1%cup` becomes `417%ml`
-
-5. **Incompatible units** (e.g., text values, or volume and mass) → Quantities won't be added and will be kept separate.
-
-### Unit selection algorithm
-
-Once the system is determined, the best unit is selected based on:
-
-1. **Candidates units**:
- - Units that belong to that system are considered potential candidates for best unit. The JP system also includes all the metric units. Certain units are disabled as not commonly used, by setting `isBestUnit` to false (default: true)
- - The units of the input quantities are restored into that list, as they are actually already used in the recipe.
-
-2. **Valid range**: A value is considered "in range" for a unit if:
- - It's between 1 and the unit's `maxValue` (default: 999), OR
- - It's less than 1 but can be approximated as a fraction (for units with fractions enabled)
-
-::: info Example: fraction-aware selection
-With US units, a value of 1.7 ml (~0.345 tsp) will select `tsp` because:
-- 0.345 ≈ 1/3, which is a valid fraction (denominator 3 is allowed)
-- `tsp` has `fractions.enabled: true`
-- Therefore 0.345 tsp is considered "in range" and is the smallest valid option
-:::
-
-3. **Selection priority** (among in-range candidates):
- - Smallest integer in the input unit family. Examples:
- - `1 cup + 1 cup` -> `2 cup` and not 1 pint
- - `0.5 pint + 0.5 pint` -> `1 pint` and not 2 cup
- - `2 cup + 1 pint` -> `2 pint` and not 4 cup
- - Smallest integers in any compatible family
- - Smallest non-integer value in range
-
-4. **Fallback**: If no candidate is in range, the unit closest to the valid range is selected. This is in particular used for potential edge cases with values above 999 liters or 999 gallons.
-
-### Per-unit configuration
-
-Each unit can have custom configuration:
-
-| Config | Description | Default |
-| ------ | ----------- | ------- |
-| `isBestUnit` | Whether a unit is eligible for best unit | true |
-| `maxValue` | Maximum value before upgrading to a larger unit | 999 |
-| `fractions.enabled` | Whether to approximate decimals as fractions | false |
-| `fractions.denominators` | Allowed denominators for fraction approximation | [2, 3, 4, 8] |
-| `fractions.maxWhole` | Maximum whole number in mixed fraction | 4 |
-
-Complete configuration for all units.
+## Units configuration
| Unit | maxValue | fractions.enabled | fractions.denominators | isBestUnit |
| ------ | -------- | ----------------- | ---------------------- | ---------- |
diff --git a/src/classes/recipe.ts b/src/classes/recipe.ts
index 53005d8..f3d0071 100644
--- a/src/classes/recipe.ts
+++ b/src/classes/recipe.ts
@@ -1312,8 +1312,8 @@ export class Recipe {
*
* @param system - The target unit system to convert to (metric, US, UK, JP)
* @param method - How to handle existing quantities:
- * - "keep": Keep all quantities, ensure target system is primary (swap if needed, or add converted)
- * - "replace": Replace primary with target system quantity, discard old primary, keep only non-target equivalents
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
* - "remove": Only keep target system quantity, delete all equivalents
* @returns A new Recipe instance with converted quantities
*
diff --git a/src/types.ts b/src/types.ts
index 7fba107..46b8911 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -119,7 +119,7 @@ export interface Metadata {
introduction?: string;
/**
* The unit system used in the recipe for ambiguous units like tsp, tbsp, cup.
- * See [Unit Systems Guide](/guide-units) for more information.
+ * See [Unit Conversion Guide](/guide-unit-conversion) for more information.
* This stores the original value as written by the user.
*/
"unit system"?: string;