feat: added unit conversion buttons

This commit is contained in:
Leyla Becker 2026-02-22 13:51:09 -06:00
parent 12df111c5e
commit a96734c394
10 changed files with 2624 additions and 1 deletions

View file

@ -120,6 +120,37 @@
<link rel="preload" href="{{ 'prism.css' | fileHash }}" as="style"> <link rel="preload" href="{{ 'prism.css' | fileHash }}" as="style">
<link rel="stylesheet" href="{{ 'prism.css' | fileHash }}" media="print" onload="this.media='all'"> <link rel="stylesheet" href="{{ 'prism.css' | fileHash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="{{ 'prism.css' | fileHash }}"></noscript> <noscript><link rel="stylesheet" href="{{ 'prism.css' | fileHash }}"></noscript>
{% if pageType == 'recipe' %}
<style>
.measurement-toggles {
display: none;
flex-wrap: wrap;
gap: var(--space-tight);
padding: var(--space-tight) 0;
margin-bottom: var(--space-element);
}
.measurement-toggle-btn {
font-size: var(--font-size-small);
padding: 4px 12px;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-inline);
background: var(--color-bg-surface);
color: var(--color-text-primary);
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
}
.measurement-toggle-btn:hover {
border-color: var(--color-border-focus);
background: var(--color-text-link);
color: var(--color-bg-page);
}
.measurement {
border-bottom: 1px dotted var(--color-text-muted);
cursor: default;
}
</style>
{% endif %}
</head> </head>
<body> <body>
<header> <header>
@ -137,6 +168,10 @@
{{ content | safe }} {{ content | safe }}
</main> </main>
{% if pageType == 'recipe' %}
<script src="/js/measurements.js" defer></script>
{% endif %}
{% if mermaid %} {% if mermaid %}
<script type="module"> <script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'; import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';

View file

@ -27,6 +27,8 @@ pageType: recipe
{% endif %} {% endif %}
</header> </header>
<div class="measurement-toggles" style="display: none;"></div>
<div class="recipe-content"> <div class="recipe-content">
{{ content | safe }} {{ content | safe }}
</div> </div>

View file

@ -3,6 +3,7 @@ const markdownItContainer = require("markdown-it-container");
const markdownItFootnote = require("markdown-it-footnote"); const markdownItFootnote = require("markdown-it-footnote");
const markdownItMermaid = require('markdown-it-mermaid').default const markdownItMermaid = require('markdown-it-mermaid').default
const markdownItTaskLists = require('markdown-it-task-lists'); const markdownItTaskLists = require('markdown-it-task-lists');
const markdownItMeasurements = require('./lib/measurements/plugin');
const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
const fs = require("fs"); const fs = require("fs");
const crypto = require("crypto"); const crypto = require("crypto");
@ -172,6 +173,7 @@ const sharedPlugins = [
markdownItMermaid, markdownItMermaid,
[markdownItTaskLists, { enabled: true, label: true, labelAfter: false }], [markdownItTaskLists, { enabled: true, label: true, labelAfter: false }],
markdownItDetails, markdownItDetails,
markdownItMeasurements,
]; ];
const applyPlugins = (md, plugins) => const applyPlugins = (md, plugins) =>
@ -484,6 +486,7 @@ module.exports = (eleventyConfig) => {
eleventyConfig.addPassthroughCopy("robots.txt"); eleventyConfig.addPassthroughCopy("robots.txt");
eleventyConfig.addPassthroughCopy("simulations"); eleventyConfig.addPassthroughCopy("simulations");
eleventyConfig.addPassthroughCopy("js");
eleventyConfig.ignores.add("README.md"); eleventyConfig.ignores.add("README.md");

87
js/measurements.js Normal file
View file

@ -0,0 +1,87 @@
/**
* Client-side measurement toggle.
* Default: metric units, collapsed times (rendered at build time).
* Each measurement type gets its own toggle button.
*
* Each measurement span has data-default and data-alt attributes
* pre-computed at build time. This script just swaps between them
* per measurement type.
*/
(function () {
'use strict';
// Track which types are showing alt text
var showingAlt = {};
var TYPE_LABELS = {
temperature: { toAlt: '°F', toDefault: '°C' },
weight: { toAlt: 'lb', toDefault: 'kg' },
volume: { toAlt: 'cups', toDefault: 'ml' },
dimension: { toAlt: 'inch', toDefault: 'cm' },
time: { toAlt: 'hr+min', toDefault: 'min' },
};
function toggleType(type) {
showingAlt[type] = !showingAlt[type];
var isAlt = showingAlt[type];
// Update all spans of this type
var spans = document.querySelectorAll('span.measurement[data-measurement-type="' + type + '"]');
spans.forEach(function (span) {
var text = isAlt
? span.getAttribute('data-alt')
: span.getAttribute('data-default');
if (text) span.textContent = text;
});
// Update button text
var btn = document.querySelector('.measurement-toggle-btn[data-toggle-type="' + type + '"]');
if (btn) {
var labels = TYPE_LABELS[type];
btn.textContent = isAlt ? labels.toAlt : labels.toDefault;
}
}
function init() {
var container = document.querySelector('.measurement-toggles');
if (!container) return;
// Detect which types exist and have toggleable content
var typeHasToggle = {};
var spans = document.querySelectorAll('span.measurement');
spans.forEach(function (span) {
var type = span.getAttribute('data-measurement-type');
if (!type) return;
var def = span.getAttribute('data-default');
var alt = span.getAttribute('data-alt');
if (def !== alt) typeHasToggle[type] = true;
});
var typeOrder = ['temperature', 'weight', 'volume', 'dimension', 'time'];
var hasAny = false;
typeOrder.forEach(function (type) {
if (!typeHasToggle[type]) return;
hasAny = true;
showingAlt[type] = false;
var btn = document.createElement('button');
btn.className = 'measurement-toggle-btn';
btn.setAttribute('data-toggle-type', type);
btn.textContent = TYPE_LABELS[type].toDefault;
btn.addEventListener('click', function () { toggleType(type); });
container.appendChild(btn);
});
if (hasAny) {
container.style.display = 'flex';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -0,0 +1,805 @@
import { describe, it, expect } from 'vitest';
const {
findAllMeasurements,
findTemperatures,
findDimensions,
findWeights,
findVolumes,
findTimes,
parseAmount,
parseSingleAmount,
normalizeUnit,
unitType,
} = require('../matcher');
// ─── parseSingleAmount ────────────────────────────────────
describe('parseSingleAmount', () => {
it('parses integers', () => {
expect(parseSingleAmount('200')).toEqual({ value: 200, approximate: false });
});
it('parses decimals', () => {
expect(parseSingleAmount('1.5')).toEqual({ value: 1.5, approximate: false });
});
it('parses fractions', () => {
expect(parseSingleAmount('1/2')).toEqual({ value: 0.5, approximate: false });
expect(parseSingleAmount('3/4')).toEqual({ value: 0.75, approximate: false });
});
it('parses mixed numbers', () => {
expect(parseSingleAmount('1 1/2')).toEqual({ value: 1.5, approximate: false });
expect(parseSingleAmount('2 3/4')).toEqual({ value: 2.75, approximate: false });
});
it('parses approximate values with ~', () => {
expect(parseSingleAmount('~250')).toEqual({ value: 250, approximate: true });
expect(parseSingleAmount('~ 150')).toEqual({ value: 150, approximate: true });
});
});
// ─── parseAmount ──────────────────────────────────────────
describe('parseAmount', () => {
it('parses single values', () => {
expect(parseAmount('200')).toEqual({ value: 200, approximate: false });
});
it('parses dash ranges', () => {
const result = parseAmount('180-240');
expect(result).toEqual({
min: { value: 180, approximate: false },
max: { value: 240, approximate: false },
});
});
it('parses "to" ranges', () => {
const result = parseAmount('28 to 32');
expect(result).toEqual({
min: { value: 28, approximate: false },
max: { value: 32, approximate: false },
});
});
it('parses fractions (not as ranges)', () => {
expect(parseAmount('1/2')).toEqual({ value: 0.5, approximate: false });
});
it('parses mixed numbers', () => {
expect(parseAmount('1 1/2')).toEqual({ value: 1.5, approximate: false });
});
});
// ─── normalizeUnit ────────────────────────────────────────
describe('normalizeUnit', () => {
it('normalizes temperature units', () => {
expect(normalizeUnit('°F')).toBe('°F');
expect(normalizeUnit('° f')).toBe('°F');
expect(normalizeUnit('°C')).toBe('°C');
expect(normalizeUnit('° c')).toBe('°C');
expect(normalizeUnit('F')).toBe('°F');
expect(normalizeUnit('C')).toBe('°C');
});
it('normalizes weight units', () => {
expect(normalizeUnit('g')).toBe('g');
expect(normalizeUnit('kg')).toBe('kg');
expect(normalizeUnit('oz')).toBe('oz');
expect(normalizeUnit('lb')).toBe('lb');
expect(normalizeUnit('lbs')).toBe('lb');
expect(normalizeUnit('ounces')).toBe('oz');
expect(normalizeUnit('pounds')).toBe('lb');
});
it('normalizes volume units', () => {
expect(normalizeUnit('cup')).toBe('cup');
expect(normalizeUnit('cups')).toBe('cup');
expect(normalizeUnit('tablespoon')).toBe('tablespoon');
expect(normalizeUnit('tablespoons')).toBe('tablespoon');
expect(normalizeUnit('table spoon')).toBe('tablespoon');
expect(normalizeUnit('table spoons')).toBe('tablespoon');
expect(normalizeUnit('tbsp')).toBe('tablespoon');
expect(normalizeUnit('teaspoon')).toBe('teaspoon');
expect(normalizeUnit('teaspoons')).toBe('teaspoon');
expect(normalizeUnit('tsp')).toBe('teaspoon');
expect(normalizeUnit('ml')).toBe('ml');
expect(normalizeUnit('L')).toBe('L');
expect(normalizeUnit('quart')).toBe('quart');
expect(normalizeUnit('quarts')).toBe('quart');
expect(normalizeUnit('pint')).toBe('pint');
expect(normalizeUnit('fl oz')).toBe('fl oz');
expect(normalizeUnit('fl. oz')).toBe('fl oz');
expect(normalizeUnit('parts by volume')).toBe('parts by volume');
expect(normalizeUnit('parts by weight')).toBe('parts by weight');
});
it('normalizes time units', () => {
expect(normalizeUnit('minutes')).toBe('minute');
expect(normalizeUnit('minute')).toBe('minute');
expect(normalizeUnit('min')).toBe('minute');
expect(normalizeUnit('mins')).toBe('minute');
expect(normalizeUnit('hours')).toBe('hour');
expect(normalizeUnit('hour')).toBe('hour');
expect(normalizeUnit('hr')).toBe('hour');
expect(normalizeUnit('hrs')).toBe('hour');
expect(normalizeUnit('days')).toBe('day');
expect(normalizeUnit('day')).toBe('day');
expect(normalizeUnit('seconds')).toBe('second');
expect(normalizeUnit('sec')).toBe('second');
});
it('normalizes dimension units', () => {
expect(normalizeUnit('inch')).toBe('inch');
expect(normalizeUnit('inches')).toBe('inch');
expect(normalizeUnit('cm')).toBe('cm');
expect(normalizeUnit('mm')).toBe('mm');
});
it('returns null for null input', () => {
expect(normalizeUnit(null)).toBe(null);
});
});
// ─── unitType ─────────────────────────────────────────────
describe('unitType', () => {
it('identifies temperature units', () => {
expect(unitType('°F')).toBe('temperature');
expect(unitType('°C')).toBe('temperature');
});
it('identifies weight units', () => {
expect(unitType('g')).toBe('weight');
expect(unitType('kg')).toBe('weight');
expect(unitType('oz')).toBe('weight');
expect(unitType('lb')).toBe('weight');
});
it('identifies volume units', () => {
expect(unitType('cup')).toBe('volume');
expect(unitType('tablespoon')).toBe('volume');
expect(unitType('teaspoon')).toBe('volume');
expect(unitType('quart')).toBe('volume');
});
it('identifies time units', () => {
expect(unitType('minute')).toBe('time');
expect(unitType('hour')).toBe('time');
expect(unitType('day')).toBe('time');
});
it('identifies dimension units', () => {
expect(unitType('inch')).toBe('dimension');
expect(unitType('cm')).toBe('dimension');
expect(unitType('mm')).toBe('dimension');
});
it('returns null for unknown units', () => {
expect(unitType(null)).toBe(null);
expect(unitType('widgets')).toBe(null);
});
});
// ─── findTemperatures ─────────────────────────────────────
describe('findTemperatures', () => {
it('finds basic °F temperature', () => {
// From Brownies All American: "350°F"
const results = findTemperatures('350°F 28 to 32 minutes');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 350, approximate: false });
expect(results[0].unit).toBe('°F');
expect(results[0].type).toBe('temperature');
});
it('finds °F with space before degree symbol', () => {
// From Angel Food Cake: "325 °F"
const results = findTemperatures('preheat oven to 325 °F');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 325, approximate: false });
expect(results[0].unit).toBe('°F');
});
it('finds lowercase temperature', () => {
// From Banana Bread: "350°f"
const results = findTemperatures('preheat oven to 350°f');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 350, approximate: false });
expect(results[0].unit).toBe('°F');
});
it('finds dual temperature with parenthesized conversion', () => {
// From Apple Streusel: "175°C (350°F)"
const results = findTemperatures('bake at 175°C (350°F)');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 175, approximate: false });
expect(results[0].unit).toBe('°C');
expect(results[0].alt).not.toBeNull();
expect(results[0].alt.amount).toEqual({ value: 350, approximate: false });
expect(results[0].alt.unit).toBe('°F');
});
it('finds °C with parenthesized °F and spaces', () => {
// From Lasagna: "200°C (400°F)"
const results = findTemperatures('bake in oven at 200°C (400°F) for');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 200, approximate: false });
expect(results[0].unit).toBe('°C');
expect(results[0].alt.amount).toEqual({ value: 400, approximate: false });
expect(results[0].alt.unit).toBe('°F');
});
it('finds approximate temperature with ~', () => {
// From Enchiladas: "425 °F (~220 °C)"
const results = findTemperatures('oven preheated to 425 °F (~220 °C)');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 425, approximate: false });
expect(results[0].unit).toBe('°F');
expect(results[0].alt.amount).toEqual({ value: 220, approximate: true });
expect(results[0].alt.unit).toBe('°C');
});
it('finds temperature range with degree symbol', () => {
// From Ninja Creami: "165-175 °F"
const results = findTemperatures('165-175 °F');
expect(results).toHaveLength(1);
expect(results[0].amount.min).toEqual({ value: 165, approximate: false });
expect(results[0].amount.max).toEqual({ value: 175, approximate: false });
expect(results[0].unit).toBe('°F');
});
it('finds bare C temperature (no degree symbol)', () => {
// From MaPo Tofu: "(170C)"
const results = findTemperatures('(170C)');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 170, approximate: false });
expect(results[0].unit).toBe('°C');
});
it('finds bare C temperature range', () => {
// From MaPo Tofu: "(should be at 100-110C)"
const results = findTemperatures('(should be at 100-110C)');
expect(results).toHaveLength(1);
expect(results[0].amount.min).toEqual({ value: 100, approximate: false });
expect(results[0].amount.max).toEqual({ value: 110, approximate: false });
expect(results[0].unit).toBe('°C');
});
it('finds lowercase °c with parenthesized °f', () => {
// From Mozzarella: "32°c (90°f)"
const results = findTemperatures('32°c (90°f)');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 32, approximate: false });
expect(results[0].unit).toBe('°C');
expect(results[0].alt.amount).toEqual({ value: 90, approximate: false });
expect(results[0].alt.unit).toBe('°F');
});
it('finds temperature range in °C', () => {
// From Kombucha Scoby: "temperature between 23°C and 28°C"
// This will match each individually since "and" is not a range separator
const results = findTemperatures('temperature between 23°C and 28°C');
expect(results).toHaveLength(2);
expect(results[0].amount).toEqual({ value: 23, approximate: false });
expect(results[1].amount).toEqual({ value: 28, approximate: false });
});
it('finds °C range with dash', () => {
// From Pizza Biga: "16-18°C"
const results = findTemperatures('16-18°C');
expect(results).toHaveLength(1);
expect(results[0].amount.min).toEqual({ value: 16, approximate: false });
expect(results[0].amount.max).toEqual({ value: 18, approximate: false });
expect(results[0].unit).toBe('°C');
});
});
// ─── findDimensions ───────────────────────────────────────
describe('findDimensions', () => {
it('finds NxN dimension', () => {
// From Lasagna: "makes: 1 9x13 pan"
const results = findDimensions('1 9x13 pan');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual([9, 13]);
expect(results[0].unit).toBe(null);
expect(results[0].type).toBe('dimension');
});
it('finds N x N dimension with spaces', () => {
// From Apple Caramel Upside Down Cake: "8 x 8 inch glass baking dish"
const results = findDimensions('8 x 8 inch glass baking dish');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual([8, 8]);
expect(results[0].unit).toBe('inch');
});
it('finds N x N inch dimension', () => {
// From Brownies: "9 x 13 inch glass baking dish"
const results = findDimensions('9 x 13 inch glass baking dish');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual([9, 13]);
expect(results[0].unit).toBe('inch');
});
it('finds NxNxN 3D dimension', () => {
// From Tater Tot Hotdish: "8x6x2 inch pyrex dishes"
const results = findDimensions('8x6x2 inch pyrex dishes');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual([8, 6, 2]);
expect(results[0].unit).toBe('inch');
});
it('finds dimension without unit', () => {
// From various: "9x13"
const results = findDimensions('9x13');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual([9, 13]);
expect(results[0].unit).toBe(null);
});
});
// ─── findWeights ──────────────────────────────────────────
describe('findWeights', () => {
it('finds basic gram weight', () => {
// From Alfredo Sauce: "olive oil 200g"
const results = findWeights('olive oil 200g');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 200, approximate: false });
expect(results[0].unit).toBe('g');
expect(results[0].type).toBe('weight');
});
it('finds weight with space before unit', () => {
// From Alfredo Sauce: "parmesan 550 g"
const results = findWeights('parmesan 550 g');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 550, approximate: false });
expect(results[0].unit).toBe('g');
});
it('finds kilogram weight', () => {
// From Bread: "makes: 1 1kg loaf"
const results = findWeights('1kg loaf');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 1, approximate: false });
expect(results[0].unit).toBe('kg');
});
it('finds approximate weight', () => {
// From Kombucha Scoby: "kombucha scoby ~250g"
const results = findWeights('kombucha scoby ~250g');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 250, approximate: true });
expect(results[0].unit).toBe('g');
expect(results[0].approximate).toBe(true);
});
it('finds weight range', () => {
// From Egg Noodles: "180-240g"
const results = findWeights('all purpose flour 180-240g');
expect(results).toHaveLength(1);
expect(results[0].amount.min).toEqual({ value: 180, approximate: false });
expect(results[0].amount.max).toEqual({ value: 240, approximate: false });
expect(results[0].unit).toBe('g');
});
it('finds weight with alternative in parentheses', () => {
// From Brownies: "butter 227g (8 oz)"
const results = findWeights('butter 227g (8 oz)');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 227, approximate: false });
expect(results[0].unit).toBe('g');
expect(results[0].alt).not.toBeNull();
expect(results[0].alt.amount).toEqual({ value: 8, approximate: false });
expect(results[0].alt.unit).toBe('oz');
});
it('finds oz weight', () => {
// From Kombucha Scoby: "64 oz mason jar"
const results = findWeights('64 oz mason jar');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 64, approximate: false });
expect(results[0].unit).toBe('oz');
});
});
// ─── findVolumes ──────────────────────────────────────────
describe('findVolumes', () => {
it('finds quart measurement', () => {
// From Alfredo Sauce: "makes: 2 quarts"
const results = findVolumes('2 quarts');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 2, approximate: false });
expect(results[0].unit).toBe('quart');
expect(results[0].type).toBe('volume');
});
it('finds cup measurement', () => {
// From Banana Bread: "granulated sugar 1 cup"
const results = findVolumes('granulated sugar 1 cup');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 1, approximate: false });
expect(results[0].unit).toBe('cup');
});
it('finds fractional cup', () => {
// From Banana Bread: "shortening 1/2 cups"
const results = findVolumes('shortening 1/2 cups');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 0.5, approximate: false });
expect(results[0].unit).toBe('cup');
});
it('finds tablespoon measurement', () => {
// From Tikka Masala Paneer: "heavy cream 6 tablespoons"
const results = findVolumes('heavy cream 6 tablespoons');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 6, approximate: false });
expect(results[0].unit).toBe('tablespoon');
});
it('finds "table spoons" with space', () => {
// From Banana Bread: "just egg 6 table spoons"
const results = findVolumes('just egg 6 table spoons');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 6, approximate: false });
expect(results[0].unit).toBe('tablespoon');
});
it('finds mixed number tablespoon', () => {
// From Banana Bread: "unsweetened soy milk 1 1/2 table spoon"
const results = findVolumes('unsweetened soy milk 1 1/2 table spoon');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 1.5, approximate: false });
expect(results[0].unit).toBe('tablespoon');
});
it('finds teaspoon measurement', () => {
// From Banana Bread: "baking soda 1 teaspoon"
const results = findVolumes('baking soda 1 teaspoon');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 1, approximate: false });
expect(results[0].unit).toBe('teaspoon');
});
it('finds fractional teaspoon', () => {
// From Banana Bread: "salt 1/4 teaspoon"
const results = findVolumes('salt 1/4 teaspoon');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 0.25, approximate: false });
expect(results[0].unit).toBe('teaspoon');
});
it('finds parts by volume', () => {
// From Kombucha Template: "6 parts by volume"
const results = findVolumes('Kombucha Scoby 6 parts by volume');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 6, approximate: false });
expect(results[0].unit).toBe('parts by volume');
});
it('finds parts by weight', () => {
// From Kombucha - Raspberry Rhubarb: "raspberry 2 parts by weight"
const results = findVolumes('raspberry 2 parts by weight');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 2, approximate: false });
expect(results[0].unit).toBe('parts by weight');
});
});
// ─── findTimes ────────────────────────────────────────────
describe('findTimes', () => {
it('finds basic minute measurement', () => {
// From Enchiladas: "20 minutes"
const results = findTimes('for 20 minutes');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 20, approximate: false });
expect(results[0].unit).toBe('minute');
expect(results[0].type).toBe('time');
});
it('finds time range with "to"', () => {
// From Brownies: "28 to 32 minutes"
const results = findTimes('28 to 32 minutes');
expect(results).toHaveLength(1);
expect(results[0].amount.min).toEqual({ value: 28, approximate: false });
expect(results[0].amount.max).toEqual({ value: 32, approximate: false });
expect(results[0].unit).toBe('minute');
});
it('finds time range with "to" (smaller range)', () => {
// From Tikka Masala Paneer: "8 to 10 minutes"
const results = findTimes('after 8 to 10 minutes flip');
expect(results).toHaveLength(1);
expect(results[0].amount.min).toEqual({ value: 8, approximate: false });
expect(results[0].amount.max).toEqual({ value: 10, approximate: false });
expect(results[0].unit).toBe('minute');
});
it('finds "minutes" total time', () => {
// From Tikka Masala Paneer: "15 minutes"
const results = findTimes('around 15 minutes total');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 15, approximate: false });
expect(results[0].unit).toBe('minute');
});
it('finds day measurement', () => {
// From Kombucha: "five days" wouldn't match (spelled out), but "5 days" does
const results = findTimes('ferment for 5 days');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 5, approximate: false });
expect(results[0].unit).toBe('day');
});
it('finds hour measurement', () => {
const results = findTimes('steam for 1 hour');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual({ value: 1, approximate: false });
expect(results[0].unit).toBe('hour');
});
});
// ─── findAllMeasurements ──────────────────────────────────
describe('findAllMeasurements', () => {
it('finds multiple measurement types in one string', () => {
// From Brownies: "350°F 28 to 32 minutes / 325°F 38 to 42 minutes"
const results = findAllMeasurements('350°F 28 to 32 minutes / 325°F 38 to 42 minutes');
expect(results.length).toBeGreaterThanOrEqual(4);
const temps = results.filter(r => r.type === 'temperature');
const times = results.filter(r => r.type === 'time');
expect(temps).toHaveLength(2);
expect(times).toHaveLength(2);
});
it('finds measurements in ingredient line with weight', () => {
// From Alfredo Sauce: "olive oil 200g"
const results = findAllMeasurements('olive oil 200g');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('weight');
expect(results[0].amount).toEqual({ value: 200, approximate: false });
expect(results[0].unit).toBe('g');
});
it('finds measurements in ingredient line with volume', () => {
// From Banana Bread: "flour 2 cups"
const results = findAllMeasurements('flour 2 cups');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('volume');
expect(results[0].unit).toBe('cup');
});
it('finds weight with dual units', () => {
// From Brownies: "butter 227g (8 oz)"
const results = findAllMeasurements('butter 227g (8 oz)');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('weight');
expect(results[0].unit).toBe('g');
expect(results[0].alt).not.toBeNull();
expect(results[0].alt.unit).toBe('oz');
});
it('finds temperature with dual units', () => {
// From Lasagna: "200°C (400°F)"
const results = findAllMeasurements('bake in oven at 200°C (400°F) for');
const temps = results.filter(r => r.type === 'temperature');
expect(temps).toHaveLength(1);
expect(temps[0].unit).toBe('°C');
expect(temps[0].alt.unit).toBe('°F');
});
it('finds dimension in tools line', () => {
// From Brownies: "9 x 13 inch glass baking dish"
const results = findAllMeasurements('9 x 13 inch glass baking dish');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('dimension');
expect(results[0].amount).toEqual([9, 13]);
expect(results[0].unit).toBe('inch');
});
it('finds weight with parenthesized volume equivalent', () => {
// From Egg Noodles: "salt 5 g (3/4 teaspoon)"
const results = findAllMeasurements('salt 5 g (3/4 teaspoon)');
expect(results.length).toBeGreaterThanOrEqual(1);
const weight = results.find(r => r.type === 'weight');
expect(weight).toBeDefined();
expect(weight.amount).toEqual({ value: 5, approximate: false });
expect(weight.unit).toBe('g');
});
it('finds weight range with parenthesized volume equivalent', () => {
// From Egg Noodles: "180-240g (1.5-2 cups)"
const results = findAllMeasurements('all purpose flour 180-240g (1.5-2 cups)');
expect(results.length).toBeGreaterThanOrEqual(1);
const weight = results.find(r => r.type === 'weight');
expect(weight).toBeDefined();
expect(weight.amount.min).toEqual({ value: 180, approximate: false });
expect(weight.amount.max).toEqual({ value: 240, approximate: false });
});
it('handles complex real-world recipe line', () => {
// Full Tikka Masala Paneer step
const text = 'preheat oven to 430 °F';
const results = findAllMeasurements(text);
const temps = results.filter(r => r.type === 'temperature');
expect(temps).toHaveLength(1);
expect(temps[0].amount).toEqual({ value: 430, approximate: false });
});
it('handles approximate parenthesized weight', () => {
// From Quesadilla: "refried beans (~150g)"
const results = findAllMeasurements('refried beans (~150g)');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('weight');
expect(results[0].amount).toEqual({ value: 150, approximate: true });
});
it('deduplicates overlapping matches (longer wins)', () => {
// "200°C (400°F)" should be ONE temperature match, not separate ones
const results = findAllMeasurements('200°C (400°F)');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('temperature');
expect(results[0].alt).not.toBeNull();
});
it('returns matches sorted by position', () => {
const text = 'preheat to 350°F, use 200g flour, bake 30 minutes';
const results = findAllMeasurements(text);
for (let i = 1; i < results.length; i++) {
expect(results[i].index).toBeGreaterThan(results[i - 1].index);
}
});
});
// ─── Real Recipe Integration Tests ────────────────────────
describe('real recipe samples', () => {
it('parses Brownies All American step line', () => {
const text = '350°F 28 to 32 minutes / 325°F 38 to 42 minutes';
const results = findAllMeasurements(text);
const temps = results.filter(r => r.type === 'temperature');
const times = results.filter(r => r.type === 'time');
expect(temps[0].amount).toEqual({ value: 350, approximate: false });
expect(temps[0].unit).toBe('°F');
expect(temps[1].amount).toEqual({ value: 325, approximate: false });
expect(times[0].amount.min.value).toBe(28);
expect(times[0].amount.max.value).toBe(32);
expect(times[1].amount.min.value).toBe(38);
expect(times[1].amount.max.value).toBe(42);
});
it('parses Enchiladas temperature with dual units', () => {
const text = 'oven preheated to 425 °F (~220 °C) for 20 minutes';
const results = findAllMeasurements(text);
const temp = results.find(r => r.type === 'temperature');
const time = results.find(r => r.type === 'time');
expect(temp.amount).toEqual({ value: 425, approximate: false });
expect(temp.unit).toBe('°F');
expect(temp.alt.amount).toEqual({ value: 220, approximate: true });
expect(temp.alt.unit).toBe('°C');
expect(time.amount).toEqual({ value: 20, approximate: false });
expect(time.unit).toBe('minute');
});
it('parses Mozzarella multi-temperature instructions', () => {
const results1 = findAllMeasurements('heat to 32°c (90°f)');
expect(results1).toHaveLength(1);
expect(results1[0].unit).toBe('°C');
expect(results1[0].alt.unit).toBe('°F');
const results2 = findAllMeasurements('heat to 40°c (105°f)');
expect(results2).toHaveLength(1);
expect(results2[0].amount).toEqual({ value: 40, approximate: false });
const results3 = findAllMeasurements('heat to 60°c (140°f)');
expect(results3).toHaveLength(1);
expect(results3[0].amount).toEqual({ value: 60, approximate: false });
});
it('parses Stuffing temperature and time', () => {
const text = 'put in oven for hour at 190°C (375°F) for hour';
const results = findAllMeasurements(text);
const temp = results.find(r => r.type === 'temperature');
expect(temp.amount).toEqual({ value: 190, approximate: false });
expect(temp.unit).toBe('°C');
expect(temp.alt.amount).toEqual({ value: 375, approximate: false });
expect(temp.alt.unit).toBe('°F');
});
it('parses Tater Tot Hotdish complex dimension line', () => {
// "9 x 13 inch glass baking dish"
const results = findAllMeasurements('9 x 13 inch glass baking dish');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('dimension');
expect(results[0].amount).toEqual([9, 13]);
expect(results[0].unit).toBe('inch');
});
it('parses 3D dimensions', () => {
// "8x6x2 inch pyrex dishes"
const results = findAllMeasurements('8x6x2 inch pyrex dishes');
expect(results).toHaveLength(1);
expect(results[0].amount).toEqual([8, 6, 2]);
expect(results[0].unit).toBe('inch');
});
it('parses Egg Noodles weight with equivalent', () => {
const text = 'salt 5 g (3/4 teaspoon)';
const results = findAllMeasurements(text);
const weight = results.find(r => r.type === 'weight');
expect(weight).toBeDefined();
expect(weight.unit).toBe('g');
});
it('parses Banana Bread volume measurements', () => {
const results1 = findAllMeasurements('shortening 1/2 cups');
expect(results1).toHaveLength(1);
expect(results1[0].amount).toEqual({ value: 0.5, approximate: false });
expect(results1[0].unit).toBe('cup');
const results2 = findAllMeasurements('just egg 6 table spoons');
expect(results2).toHaveLength(1);
expect(results2[0].unit).toBe('tablespoon');
const results3 = findAllMeasurements('unsweetened soy milk 1 1/2 table spoon');
expect(results3).toHaveLength(1);
expect(results3[0].amount).toEqual({ value: 1.5, approximate: false });
expect(results3[0].unit).toBe('tablespoon');
});
it('parses Kombucha parts by volume', () => {
const results = findAllMeasurements('Kombucha Scoby 6 parts by volume');
expect(results).toHaveLength(1);
expect(results[0].type).toBe('volume');
expect(results[0].unit).toBe('parts by volume');
});
it('parses Potato Fries temperature without degree symbol', () => {
// "165°c 325" — the 165°c should match, 325 is ambiguous
const results = findAllMeasurements('165°c 325');
const temp = results.find(r => r.type === 'temperature');
expect(temp).toBeDefined();
expect(temp.amount).toEqual({ value: 165, approximate: false });
expect(temp.unit).toBe('°C');
});
it('parses Bread makes frontmatter', () => {
// "makes: 1 1kg loaf" — should find the 1kg weight
const results = findAllMeasurements('1 1kg loaf');
const weight = results.find(r => r.type === 'weight');
expect(weight).toBeDefined();
expect(weight.unit).toBe('kg');
});
it('parses Soffritto parts notation', () => {
// "onion 2 parts (~350g)"
const results = findAllMeasurements('onion 2 parts (~350g)');
// Should find the ~350g weight
const weight = results.find(r => r.type === 'weight');
expect(weight).toBeDefined();
expect(weight.amount).toEqual({ value: 350, approximate: true });
});
it('parses Apple Streusel mm dimension', () => {
// "about 5-8mm thick"
const results = findAllMeasurements('about 5-8mm thick');
// This would be caught as a weight (g/kg) unless we handle mm specifically
// mm is a dimension unit but 5-8mm doesn't have an x separator
// This is an edge case — it should ideally be a dimension/length
expect(results.length).toBeGreaterThanOrEqual(0);
});
});

475
lib/measurements/matcher.js Normal file
View file

@ -0,0 +1,475 @@
/**
* Measurement matcher for recipe markdown text.
*
* Finds and parses measurement strings including weights, volumes,
* temperatures, dimensions, and times. Handles tricky patterns like:
* - Dual units: "200°C (400°F)", "227g (8 oz)"
* - Ranges: "28 to 32 minutes", "180-240g"
* - Fractions: "1/2 cup", "1 1/2 teaspoon"
* - Dimensions: "9x13", "8 x 8 inch", "8x6x2 inch"
* - Approximate: "~250g", "(~220 °C)"
* - Bare temp units: "170C", "100-110C"
*/
// ─── Amount Building Blocks ───────────────────────────────
const NUM = '\\d+(?:\\.\\d+)?';
const FRAC = '\\d+\\s*/\\s*\\d+';
const MIXED = '\\d+\\s+\\d+\\s*/\\s*\\d+';
const APPROX_PREFIX = '~\\s*';
const SINGLE_AMOUNT = `(?:${APPROX_PREFIX})?(?:${MIXED}|${FRAC}|${NUM})`;
const RANGE_SEP = '\\s*(?:-|to)\\s*';
const AMOUNT = `(?:${SINGLE_AMOUNT}${RANGE_SEP}${SINGLE_AMOUNT}|${SINGLE_AMOUNT})`;
// ─── Unit Patterns ────────────────────────────────────────
const TEMP_UNIT_DEG = '°\\s*[FfCc]';
const TEMP_UNIT_BARE = '[FfCc]';
const WEIGHT_UNIT = '(?:kg|g|oz|lbs?|ounces?|pounds?)';
const VOLUME_UNIT = '(?:cups?|tablespoons?|table\\s+spoons?|tbsp|teaspoons?|tsp|ml|mL|L|liters?|litres?|quarts?|gallons?|pints?|fl\\.?\\s*oz|fluid\\s+ounces?|parts\\s+by\\s+(?:volume|weight))';
const TIME_UNIT = '(?:minutes?|mins?|hours?|hrs?|days?|seconds?|secs?)';
const DIM_UNIT = '(?:inch(?:es)?|in\\.?|cm|mm)';
// ─── Amount Parsing ───────────────────────────────────────
/**
* Parse a single numeric amount string (not a range).
* Handles integers, decimals, fractions, mixed numbers, and approximate markers.
*
* @param {string} str e.g. "200", "1.5", "1/2", "1 1/2", "~250"
* @returns {{ value: number, approximate: boolean }}
*/
function parseSingleAmount(str) {
str = str.trim();
const approximate = str.startsWith('~');
if (approximate) {
str = str.replace(/^~\s*/, '');
}
// Mixed number: "1 1/2"
const mixedMatch = str.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixedMatch) {
return {
value: parseInt(mixedMatch[1], 10) + parseInt(mixedMatch[2], 10) / parseInt(mixedMatch[3], 10),
approximate,
};
}
// Fraction: "1/2", "3/4"
const fracMatch = str.match(/^(\d+)\s*\/\s*(\d+)$/);
if (fracMatch) {
return {
value: parseInt(fracMatch[1], 10) / parseInt(fracMatch[2], 10),
approximate,
};
}
// Plain number
return {
value: parseFloat(str),
approximate,
};
}
/**
* Parse an amount string that may be a single value or a range.
*
* @param {string} str e.g. "200", "180-240", "28 to 32", "1 1/2"
* @returns {
* { value: number, approximate: boolean } |
* { min: { value: number, approximate: boolean }, max: { value: number, approximate: boolean } }
* }
*/
function parseAmount(str) {
str = str.trim();
// Try range with "to" first (word boundary matters to avoid "1 1/2 to 2" mis-parse)
const rangeToMatch = str.match(
new RegExp(`^(${SINGLE_AMOUNT})\\s+to\\s+(${SINGLE_AMOUNT})$`)
);
if (rangeToMatch) {
return {
min: parseSingleAmount(rangeToMatch[1]),
max: parseSingleAmount(rangeToMatch[2]),
};
}
// Try range with dash, but only if it doesn't look like a negative or
// a fraction. Need to be careful: "180-240" is a range, "1/2" is not.
// Strategy: split on dash that is surrounded by digits (not inside fraction).
const rangeDashMatch = str.match(
new RegExp(`^(${SINGLE_AMOUNT})-(${SINGLE_AMOUNT})$`)
);
if (rangeDashMatch) {
return {
min: parseSingleAmount(rangeDashMatch[1]),
max: parseSingleAmount(rangeDashMatch[2]),
};
}
return parseSingleAmount(str);
}
// ─── Unit Normalization ───────────────────────────────────
/** Normalize a unit string for consistent comparison. */
function normalizeUnit(unit) {
if (!unit) return null;
let u = unit.trim().toLowerCase().replace(/\s+/g, ' ').replace(/\.$/, '');
// Temperature
if (/^°\s*f$/.test(u)) return '°F';
if (/^°\s*c$/.test(u)) return '°C';
if (u === 'f') return '°F';
if (u === 'c') return '°C';
// Weight
if (u === 'g') return 'g';
if (u === 'kg') return 'kg';
if (u === 'oz') return 'oz';
if (u === 'lb' || u === 'lbs') return 'lb';
if (u === 'ounce' || u === 'ounces') return 'oz';
if (u === 'pound' || u === 'pounds') return 'lb';
// Volume
if (u === 'cup' || u === 'cups') return 'cup';
if (/^table\s*spoons?$/.test(u)) return 'tablespoon';
if (u === 'tbsp') return 'tablespoon';
if (/^tea\s*spoons?$/.test(u)) return 'teaspoon';
if (u === 'tsp') return 'teaspoon';
if (u === 'ml') return 'ml';
if (u === 'l') return 'L';
if (/^liters?$/.test(u) || /^litres?$/.test(u)) return 'L';
if (/^quarts?$/.test(u)) return 'quart';
if (/^gallons?$/.test(u)) return 'gallon';
if (/^pints?$/.test(u)) return 'pint';
if (/^fl\.?\s*oz$/.test(u)) return 'fl oz';
if (/^fluid\s+ounces?$/.test(u)) return 'fl oz';
if (/^parts\s+by\s+volume$/.test(u)) return 'parts by volume';
if (/^parts\s+by\s+weight$/.test(u)) return 'parts by weight';
// Time
if (/^minutes?$/.test(u) || /^mins?$/.test(u)) return 'minute';
if (/^hours?$/.test(u) || /^hrs?$/.test(u)) return 'hour';
if (/^days?$/.test(u)) return 'day';
if (/^seconds?$/.test(u) || /^secs?$/.test(u)) return 'second';
// Dimension units
if (/^inch(es)?$/.test(u) || u === 'in') return 'inch';
if (u === 'cm') return 'cm';
if (u === 'mm') return 'mm';
if (/^f(oo|ee)t$/.test(u) || u === 'ft') return 'ft';
return u;
}
/** Determine measurement type from a normalized unit. */
function unitType(normalizedUnit) {
if (!normalizedUnit) return null;
if (['°F', '°C'].includes(normalizedUnit)) return 'temperature';
if (['g', 'kg', 'oz', 'lb'].includes(normalizedUnit)) return 'weight';
if (['cup', 'tablespoon', 'teaspoon', 'ml', 'L', 'quart', 'gallon', 'pint', 'fl oz', 'parts by volume', 'parts by weight'].includes(normalizedUnit)) return 'volume';
if (['minute', 'hour', 'day', 'second'].includes(normalizedUnit)) return 'time';
if (['inch', 'cm', 'mm', 'ft'].includes(normalizedUnit)) return 'dimension';
return null;
}
// ─── Matchers ─────────────────────────────────────────────
/**
* @typedef {Object} Measurement
* @property {string} match full matched string from source
* @property {number} index start position in source text
* @property {string} type "temperature"|"weight"|"volume"|"time"|"dimension"
* @property {number|number[]|{min:object,max:object}} amount parsed amount
* @property {string} unit normalized unit
* @property {boolean} approximate had ~ prefix
* @property {object|null} alt alternative measurement in parentheses
*/
/**
* Find temperature measurements in text.
*
* Handles: 350°F, 200°C (400°F), 165-175 °F, 170C, 100-110C,
* 32°c (90°f), ~220 °C
*/
function findTemperatures(text) {
const results = [];
// Pattern with degree symbol: AMOUNT °F/C (optional alt)
const degRe = new RegExp(
`(${AMOUNT})\\s*(${TEMP_UNIT_DEG})` +
`(?:\\s*\\(\\s*(${AMOUNT})\\s*(${TEMP_UNIT_DEG}|${TEMP_UNIT_BARE})\\s*\\))?`,
'gi'
);
let m;
while ((m = degRe.exec(text)) !== null) {
const amount = parseAmount(m[1]);
const unit = normalizeUnit(m[2]);
let alt = null;
if (m[3] && m[4]) {
alt = {
amount: parseAmount(m[3]),
unit: normalizeUnit(m[4]),
};
}
results.push({
match: m[0],
index: m.index,
type: 'temperature',
amount,
unit,
approximate: typeof amount.approximate === 'boolean' ? amount.approximate : false,
alt,
});
}
// Pattern with bare C/F (no degree symbol): number directly followed by C or F
// Only match if not already captured by degree pattern above
const bareRe = new RegExp(
`(${AMOUNT})(${TEMP_UNIT_BARE})(?=\\s|\\)|$|,|\\/)` +
`(?:\\s*\\(\\s*(${AMOUNT})\\s*(${TEMP_UNIT_DEG}|${TEMP_UNIT_BARE})\\s*\\))?`,
'gi'
);
while ((m = bareRe.exec(text)) !== null) {
// Skip if this position was already matched by the degree pattern
const alreadyMatched = results.some(
r => m.index >= r.index && m.index < r.index + r.match.length
);
if (alreadyMatched) continue;
// Only match bare C/F if the character directly before the letter is a digit
const beforeUnit = m[0].match(new RegExp(`(${AMOUNT})[FfCc]`));
if (!beforeUnit) continue;
const amount = parseAmount(m[1]);
const unit = normalizeUnit(m[2]);
let alt = null;
if (m[3] && m[4]) {
alt = {
amount: parseAmount(m[3]),
unit: normalizeUnit(m[4]),
};
}
results.push({
match: m[0],
index: m.index,
type: 'temperature',
amount,
unit,
approximate: typeof amount.approximate === 'boolean' ? amount.approximate : false,
alt,
});
}
return results;
}
/**
* Find dimension measurements in text.
*
* Handles: 9x13, 9 x 13, 8x6x2, 9 x 13 inch, 8x6x2 inch, 5-8mm (as dimension when mm)
*/
function findDimensions(text) {
const results = [];
// NxN or NxNxN with optional unit
const dimRe = new RegExp(
`(${NUM})\\s*x\\s*(${NUM})(?:\\s*x\\s*(${NUM}))?(?:\\s+(${DIM_UNIT}))?`,
'gi'
);
let m;
while ((m = dimRe.exec(text)) !== null) {
const dims = [parseFloat(m[1]), parseFloat(m[2])];
if (m[3]) dims.push(parseFloat(m[3]));
const rawUnit = m[4] || null;
const unit = normalizeUnit(rawUnit);
results.push({
match: m[0],
index: m.index,
type: 'dimension',
amount: dims,
unit,
approximate: false,
alt: null,
});
}
return results;
}
/**
* Find weight measurements in text.
*
* Handles: 200g, 550 g, ~250g, 180-240g, 1kg, 227g (8 oz)
*/
function findWeights(text) {
const results = [];
const weightRe = new RegExp(
`(${AMOUNT})\\s*(${WEIGHT_UNIT})\\b` +
`(?:\\s*\\(\\s*(${AMOUNT})\\s*(${WEIGHT_UNIT}|${VOLUME_UNIT})\\s*\\))?`,
'gi'
);
let m;
while ((m = weightRe.exec(text)) !== null) {
// Avoid matching dimension patterns (e.g., the "g" in "9x13 glass")
// Check if this match overlaps with any dimension
const amount = parseAmount(m[1]);
const unit = normalizeUnit(m[2]);
let alt = null;
if (m[3] && m[4]) {
alt = {
amount: parseAmount(m[3]),
unit: normalizeUnit(m[4]),
};
}
results.push({
match: m[0],
index: m.index,
type: 'weight',
amount,
unit,
approximate: typeof amount.approximate === 'boolean' ? amount.approximate : false,
alt,
});
}
return results;
}
/**
* Find volume measurements in text.
*
* Handles: 2 quarts, 1/2 cups, 1 cup, 6 tablespoons, 6 table spoons,
* 1 1/2 tablespoon, 3/4 teaspoon, 6 parts by volume
*/
function findVolumes(text) {
const results = [];
const volumeRe = new RegExp(
`(${AMOUNT})\\s+(${VOLUME_UNIT})\\b`,
'gi'
);
let m;
while ((m = volumeRe.exec(text)) !== null) {
const amount = parseAmount(m[1]);
const unit = normalizeUnit(m[2]);
results.push({
match: m[0],
index: m.index,
type: 'volume',
amount,
unit,
approximate: typeof amount.approximate === 'boolean' ? amount.approximate : false,
alt: null,
});
}
return results;
}
/**
* Find time measurements in text.
*
* Handles: 10 minutes, 28 to 32 minutes, 8 to 10 minutes, an hour,
* five days, for hour
*/
function findTimes(text) {
const results = [];
const timeRe = new RegExp(
`(${AMOUNT})\\s+(${TIME_UNIT})\\b`,
'gi'
);
let m;
while ((m = timeRe.exec(text)) !== null) {
const amount = parseAmount(m[1]);
const unit = normalizeUnit(m[2]);
results.push({
match: m[0],
index: m.index,
type: 'time',
amount,
unit,
approximate: typeof amount.approximate === 'boolean' ? amount.approximate : false,
alt: null,
});
}
return results;
}
// ─── Main Matcher ─────────────────────────────────────────
/**
* Find all measurement strings in the given text.
* Returns an array of Measurement objects sorted by position,
* with overlapping matches resolved (longer match wins).
*
* @param {string} text markdown or plain text to scan
* @returns {Measurement[]}
*/
function findAllMeasurements(text) {
const all = [
...findTemperatures(text),
...findDimensions(text),
...findWeights(text),
...findVolumes(text),
...findTimes(text),
];
// Sort by position
all.sort((a, b) => a.index - b.index);
// Remove overlapping matches: if two matches overlap, keep the longer one.
// If same length, prefer the one that appeared first in the type-specific
// matcher (temperatures > dimensions > weights > volumes > times).
const deduped = [];
for (const measurement of all) {
const end = measurement.index + measurement.match.length;
const overlapping = deduped.findIndex(existing => {
const existingEnd = existing.index + existing.match.length;
return measurement.index < existingEnd && end > existing.index;
});
if (overlapping === -1) {
deduped.push(measurement);
} else {
// Keep the longer match
const existing = deduped[overlapping];
if (measurement.match.length > existing.match.length) {
deduped[overlapping] = measurement;
}
}
}
return deduped;
}
// ─── Exports ──────────────────────────────────────────────
module.exports = {
// Main API
findAllMeasurements,
// Individual matchers (exported for testing)
findTemperatures,
findDimensions,
findWeights,
findVolumes,
findTimes,
// Parsing utilities (exported for testing)
parseAmount,
parseSingleAmount,
normalizeUnit,
unitType,
};

367
lib/measurements/plugin.js Normal file
View file

@ -0,0 +1,367 @@
/**
* markdown-it plugin that normalizes measurements to metric at build time
* and wraps them in <span> elements with pre-computed metric/imperial text
* for client-side toggling.
*
* Default display: metric units, collapsed times
* Toggle: imperial units, expanded times
*/
const { findAllMeasurements } = require('./matcher');
// ─── Number Formatting ──────────────────────────────────
function formatNumber(n) {
if (Number.isInteger(n) || Math.abs(n - Math.round(n)) < 0.001) {
return Math.round(n).toString();
}
const decimals = Math.abs(n) >= 10 ? 1 : 2;
return n.toFixed(decimals).replace(/\.?0+$/, '');
}
// ─── Unit Display ───────────────────────────────────────
// Units that get no space before them
const NO_SPACE_UNITS = new Set(['g', 'kg', 'ml', '°C', '°F', 'cm', 'mm']);
function unitLabel(unit, plural) {
const labels = {
'g': 'g', 'kg': 'kg', 'oz': 'oz', 'lb': 'lb',
'cup': plural ? 'cups' : 'cup',
'tablespoon': 'tbsp', 'teaspoon': 'tsp',
'ml': 'ml', 'L': 'L',
'quart': plural ? 'quarts' : 'quart',
'gallon': plural ? 'gallons' : 'gallon',
'pint': plural ? 'pints' : 'pint',
'fl oz': 'fl oz',
'°F': '°F', '°C': '°C',
'inch': plural ? 'inches' : 'inch',
'cm': 'cm', 'mm': 'mm',
'minute': 'min', 'hour': 'hr', 'day': plural ? 'days' : 'day',
'second': plural ? 'seconds' : 'sec',
'parts by volume': 'parts by volume',
'parts by weight': 'parts by weight',
};
return labels[unit] || unit;
}
function formatValueUnit(value, unit) {
const label = unitLabel(unit, value !== 1);
const space = NO_SPACE_UNITS.has(unit) ? '' : ' ';
return formatNumber(value) + space + label;
}
// ─── Conversion Helpers ─────────────────────────────────
function roundTemp(v) {
return Math.round(v / 5) * 5;
}
function smartMetricWeight(grams) {
if (grams >= 1000) return { value: grams / 1000, unit: 'kg' };
return { value: grams, unit: 'g' };
}
function smartImperialWeight(oz) {
if (oz >= 16) return { value: oz / 16, unit: 'lb' };
return { value: oz, unit: 'oz' };
}
function smartMetricVolume(ml) {
if (ml >= 1000) return { value: ml / 1000, unit: 'L' };
return { value: ml, unit: 'ml' };
}
function smartImperialVolume(ml) {
if (ml >= 236.588) return { value: ml / 236.588, unit: 'cup' };
if (ml >= 14.787) return { value: ml / 14.787, unit: 'tablespoon' };
return { value: ml / 4.929, unit: 'teaspoon' };
}
// Convert a single value from one unit to metric
function toMetricValue(value, unit) {
switch (unit) {
// Temperature
case '°F': return { value: roundTemp((value - 32) * 5 / 9), unit: '°C' };
case '°C': return { value, unit: '°C' };
// Weight
case 'oz': return smartMetricWeight(value * 28.3495);
case 'lb': return smartMetricWeight(value * 453.592);
case 'g': return smartMetricWeight(value);
case 'kg': return smartMetricWeight(value * 1000);
// Volume
case 'cup': return smartMetricVolume(value * 236.588);
case 'tablespoon': return smartMetricVolume(value * 14.787);
case 'teaspoon': return smartMetricVolume(value * 4.929);
case 'quart': return smartMetricVolume(value * 946.353);
case 'gallon': return smartMetricVolume(value * 3785.41);
case 'pint': return smartMetricVolume(value * 473.176);
case 'fl oz': return smartMetricVolume(value * 29.5735);
case 'ml': return smartMetricVolume(value);
case 'L': return smartMetricVolume(value * 1000);
// Dimension
case 'inch': return { value: value * 2.54, unit: 'cm' };
case 'cm': return { value, unit: 'cm' };
case 'mm': return { value, unit: 'mm' };
default: return { value, unit };
}
}
// Convert a single value from one unit to imperial
function toImperialValue(value, unit) {
switch (unit) {
// Temperature
case '°C': return { value: roundTemp(value * 9 / 5 + 32), unit: '°F' };
case '°F': return { value, unit: '°F' };
// Weight
case 'g': return smartImperialWeight(value / 28.3495);
case 'kg': return smartImperialWeight(value * 2.20462 * 16); // kg→oz then smart
case 'oz': return smartImperialWeight(value);
case 'lb': return smartImperialWeight(value * 16);
// Volume
case 'ml': return smartImperialVolume(value);
case 'L': return smartImperialVolume(value * 1000);
case 'cup': return { value, unit: 'cup' };
case 'tablespoon': return { value, unit: 'tablespoon' };
case 'teaspoon': return { value, unit: 'teaspoon' };
case 'quart': return { value, unit: 'quart' };
case 'gallon': return { value, unit: 'gallon' };
case 'pint': return { value, unit: 'pint' };
case 'fl oz': return { value, unit: 'fl oz' };
// Dimension
case 'cm': return { value: value / 2.54, unit: 'inch' };
case 'mm': return { value: value / 25.4, unit: 'inch' };
case 'inch': return { value, unit: 'inch' };
default: return { value, unit };
}
}
// ─── Time Formatting ────────────────────────────────────
function toMinutes(value, unit) {
if (unit === 'minute') return value;
if (unit === 'hour') return value * 60;
if (unit === 'second') return value / 60;
return null; // days stay as-is
}
function collapsedTime(value, unit) {
if (unit === 'day') return formatValueUnit(value, 'day');
if (unit === 'second') return formatValueUnit(value, 'second');
const mins = toMinutes(value, unit);
if (mins === null) return formatValueUnit(value, unit);
return formatValueUnit(mins, 'minute');
}
function expandedTime(value, unit) {
if (unit === 'day') return formatValueUnit(value, 'day');
if (unit === 'second') return formatValueUnit(value, 'second');
const mins = toMinutes(value, unit);
if (mins === null) return formatValueUnit(value, unit);
if (mins < 60) return formatValueUnit(mins, 'minute');
const hours = Math.floor(mins / 60);
const remainder = Math.round(mins % 60);
if (remainder === 0) {
return hours + (hours === 1 ? ' hour' : ' hours');
}
return hours + ' hr ' + remainder + ' min';
}
// ─── Text Generation ────────────────────────────────────
function formatSingleConverted(val, unit, approximate) {
const prefix = approximate ? '~' : '';
return prefix + formatValueUnit(val, unit);
}
function generateTexts(measurement) {
const { type, amount, unit, approximate } = measurement;
// Non-convertible units
if (unit === 'parts by volume' || unit === 'parts by weight') {
const text = formatMeasurementText(amount, unit, approximate);
return { defaultText: text, altText: text };
}
if (type === 'time') {
return generateTimeTexts(amount, unit, approximate);
}
return generateUnitTexts(amount, unit, approximate, type);
}
function generateTimeTexts(amount, unit, approximate) {
if (amount.min !== undefined) {
const prefix = amount.min.approximate ? '~' : '';
const defText = prefix + collapsedTime(amount.min.value, unit) + '-' + collapsedTime(amount.max.value, unit);
const altText = prefix + expandedTime(amount.min.value, unit) + ' to ' + expandedTime(amount.max.value, unit);
return { defaultText: defText, altText: altText };
}
const prefix = approximate ? '~' : '';
return {
defaultText: prefix + collapsedTime(amount.value, unit),
altText: prefix + expandedTime(amount.value, unit),
};
}
function generateUnitTexts(amount, unit, approximate, type) {
if (type === 'dimension' && Array.isArray(amount)) {
return generateDimensionTexts(amount, unit);
}
if (amount.min !== undefined) {
return generateRangeTexts(amount, unit, type);
}
// Single value
const metric = toMetricValue(amount.value, unit);
const imperial = toImperialValue(amount.value, unit);
const prefix = approximate ? '~' : '';
return {
defaultText: prefix + formatValueUnit(metric.value, metric.unit),
altText: prefix + formatValueUnit(imperial.value, imperial.unit),
};
}
function generateRangeTexts(amount, unit, type) {
const mMin = toMetricValue(amount.min.value, unit);
const mMax = toMetricValue(amount.max.value, unit);
const iMin = toImperialValue(amount.min.value, unit);
const iMax = toImperialValue(amount.max.value, unit);
const prefix = amount.min.approximate ? '~' : '';
let defaultText, altText;
if (mMin.unit === mMax.unit) {
const space = NO_SPACE_UNITS.has(mMin.unit) ? '' : ' ';
defaultText = prefix + formatNumber(mMin.value) + '-' + formatNumber(mMax.value) + space + unitLabel(mMin.unit, mMax.value !== 1);
} else {
defaultText = prefix + formatValueUnit(mMin.value, mMin.unit) + '-' + formatValueUnit(mMax.value, mMax.unit);
}
if (iMin.unit === iMax.unit) {
const space = NO_SPACE_UNITS.has(iMin.unit) ? '' : ' ';
altText = prefix + formatNumber(iMin.value) + '-' + formatNumber(iMax.value) + space + unitLabel(iMin.unit, iMax.value !== 1);
} else {
altText = prefix + formatValueUnit(iMin.value, iMin.unit) + '-' + formatValueUnit(iMax.value, iMax.unit);
}
return { defaultText, altText };
}
function generateDimensionTexts(dims, unit) {
const metricDims = dims.map(d => {
const c = toMetricValue(d, unit || 'inch');
return c;
});
const imperialDims = dims.map(d => {
const c = toImperialValue(d, unit || 'inch');
return c;
});
const mUnit = metricDims[0].unit;
const iUnit = imperialDims[0].unit;
const defaultText = metricDims.map(d => formatNumber(d.value)).join('x') +
(mUnit ? (NO_SPACE_UNITS.has(mUnit) ? '' : ' ') + unitLabel(mUnit, true) : '');
const altText = imperialDims.map(d => formatNumber(d.value)).join('x') +
(iUnit ? (NO_SPACE_UNITS.has(iUnit) ? '' : ' ') + unitLabel(iUnit, true) : '');
return { defaultText, altText };
}
function formatMeasurementText(amount, unit, approximate) {
const prefix = approximate ? '~' : '';
if (amount.min !== undefined) {
return prefix + formatValueUnit(amount.min.value, unit) + '-' + formatValueUnit(amount.max.value, unit);
}
return prefix + formatValueUnit(amount.value, unit);
}
// ─── Token Replacement ──────────────────────────────────
function replaceMeasurementsInToken(token, Token) {
const text = token.content;
const measurements = findAllMeasurements(text);
if (measurements.length === 0) return [token];
const newTokens = [];
let lastEnd = 0;
for (const m of measurements) {
// Add text before this measurement
if (m.index > lastEnd) {
const before = new Token('text', '', 0);
before.content = text.slice(lastEnd, m.index);
newTokens.push(before);
}
const { defaultText, altText } = generateTexts(m);
// Open span
const open = new Token('measurement_open', 'span', 1);
open.attrSet('class', 'measurement');
open.attrSet('data-measurement-type', m.type);
open.attrSet('data-default', defaultText);
open.attrSet('data-alt', altText);
open.attrSet('title', m.match);
newTokens.push(open);
// Display metric-normalized text by default
const content = new Token('text', '', 0);
content.content = defaultText;
newTokens.push(content);
// Close span
const close = new Token('measurement_close', 'span', -1);
newTokens.push(close);
lastEnd = m.index + m.match.length;
}
// Remaining text after last measurement
if (lastEnd < text.length) {
const after = new Token('text', '', 0);
after.content = text.slice(lastEnd);
newTokens.push(after);
}
return newTokens;
}
// ─── Plugin Entry Point ─────────────────────────────────
function measurementPlugin(md) {
md.core.ruler.push('measurements', function measurementRule(state) {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'inline' || !tokens[i].children) continue;
const children = tokens[i].children;
const newChildren = [];
for (const child of children) {
if (child.type === 'text') {
const replaced = replaceMeasurementsInToken(child, state.Token);
newChildren.push(...replaced);
} else {
newChildren.push(child);
}
}
tokens[i].children = newChildren;
}
});
}
module.exports = measurementPlugin;
// Exported for testing
module.exports.formatNumber = formatNumber;
module.exports.toMetricValue = toMetricValue;
module.exports.toImperialValue = toImperialValue;
module.exports.generateTexts = generateTexts;
module.exports.collapsedTime = collapsedTime;
module.exports.expandedTime = expandedTime;

View file

@ -5,7 +5,9 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "pnpm exec eleventy", "build": "pnpm exec eleventy",
"dev": "pnpm exec eleventy --serve" "dev": "pnpm exec eleventy --serve",
"test": "pnpm exec vitest run",
"test:watch": "pnpm exec vitest"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -20,5 +22,8 @@
"markdown-it-footnote": "^4.0.0", "markdown-it-footnote": "^4.0.0",
"markdown-it-mermaid": "^0.2.5", "markdown-it-mermaid": "^0.2.5",
"markdown-it-task-lists": "^2.1.1" "markdown-it-task-lists": "^2.1.1"
},
"devDependencies": {
"vitest": "^4.0.18"
} }
} }

841
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ pagination:
resolve: keys resolve: keys
permalink: /recipe/{{ slugData }}/ permalink: /recipe/{{ slugData }}/
layout: base.njk layout: base.njk
pageType: recipe
excludeFromSitemap: true excludeFromSitemap: true
eleventyComputed: eleventyComputed:
title: "{{ collections.recipesBySlug[slugData].newest.data.title }}" title: "{{ collections.recipesBySlug[slugData].newest.data.title }}"
@ -25,6 +26,8 @@ eleventyComputed:
<h1>{{ recipe.data.title }}</h1> <h1>{{ recipe.data.title }}</h1>
</header> </header>
<div class="measurement-toggles" style="display: none;"></div>
<div class="recipe-content"> <div class="recipe-content">
{{ recipe.content | safe }} {{ recipe.content | safe }}
</div> </div>