feat: added unit conversion buttons
This commit is contained in:
parent
12df111c5e
commit
a96734c394
10 changed files with 2624 additions and 1 deletions
|
|
@ -120,6 +120,37 @@
|
|||
<link rel="preload" href="{{ 'prism.css' | fileHash }}" as="style">
|
||||
<link rel="stylesheet" href="{{ 'prism.css' | fileHash }}" media="print" onload="this.media='all'">
|
||||
<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>
|
||||
<body>
|
||||
<header>
|
||||
|
|
@ -137,6 +168,10 @@
|
|||
{{ content | safe }}
|
||||
</main>
|
||||
|
||||
{% if pageType == 'recipe' %}
|
||||
<script src="/js/measurements.js" defer></script>
|
||||
{% endif %}
|
||||
|
||||
{% if mermaid %}
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ pageType: recipe
|
|||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="measurement-toggles" style="display: none;"></div>
|
||||
|
||||
<div class="recipe-content">
|
||||
{{ content | safe }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const markdownItContainer = require("markdown-it-container");
|
|||
const markdownItFootnote = require("markdown-it-footnote");
|
||||
const markdownItMermaid = require('markdown-it-mermaid').default
|
||||
const markdownItTaskLists = require('markdown-it-task-lists');
|
||||
const markdownItMeasurements = require('./lib/measurements/plugin');
|
||||
const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
|
@ -172,6 +173,7 @@ const sharedPlugins = [
|
|||
markdownItMermaid,
|
||||
[markdownItTaskLists, { enabled: true, label: true, labelAfter: false }],
|
||||
markdownItDetails,
|
||||
markdownItMeasurements,
|
||||
];
|
||||
|
||||
const applyPlugins = (md, plugins) =>
|
||||
|
|
@ -484,6 +486,7 @@ module.exports = (eleventyConfig) => {
|
|||
|
||||
eleventyConfig.addPassthroughCopy("robots.txt");
|
||||
eleventyConfig.addPassthroughCopy("simulations");
|
||||
eleventyConfig.addPassthroughCopy("js");
|
||||
|
||||
eleventyConfig.ignores.add("README.md");
|
||||
|
||||
|
|
|
|||
87
js/measurements.js
Normal file
87
js/measurements.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
805
lib/measurements/__tests__/matcher.test.js
Normal file
805
lib/measurements/__tests__/matcher.test.js
Normal 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
475
lib/measurements/matcher.js
Normal 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
367
lib/measurements/plugin.js
Normal 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;
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
"main": "index.js",
|
||||
"scripts": {
|
||||
"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": [],
|
||||
"author": "",
|
||||
|
|
@ -20,5 +22,8 @@
|
|||
"markdown-it-footnote": "^4.0.0",
|
||||
"markdown-it-mermaid": "^0.2.5",
|
||||
"markdown-it-task-lists": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
841
pnpm-lock.yaml
generated
841
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ pagination:
|
|||
resolve: keys
|
||||
permalink: /recipe/{{ slugData }}/
|
||||
layout: base.njk
|
||||
pageType: recipe
|
||||
excludeFromSitemap: true
|
||||
eleventyComputed:
|
||||
title: "{{ collections.recipesBySlug[slugData].newest.data.title }}"
|
||||
|
|
@ -25,6 +26,8 @@ eleventyComputed:
|
|||
<h1>{{ recipe.data.title }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="measurement-toggles" style="display: none;"></div>
|
||||
|
||||
<div class="recipe-content">
|
||||
{{ recipe.content | safe }}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue