feat: added support for adding plurality to ingredients

This commit is contained in:
Leyla Becker 2026-02-22 19:53:18 -06:00
parent 750bfb912f
commit 8855d6ba19
9 changed files with 468 additions and 8 deletions

View file

@ -518,4 +518,98 @@ describe('density with approximate values', () => {
expect(weightAttrs.length).toBe(1);
expect(weightAttrs[0].weightDefault).toMatch(/^~/);
});
});
// ─── Count Noun Pluralization ───────────────────────────────
describe('count noun pluralization', () => {
it('generates noun span for known singular noun', () => {
const html = render('- onion 1');
expect(html).toContain('class="count-noun"');
expect(html).toContain('data-singular="onion"');
expect(html).toContain('data-plural="onions"');
});
it('generates noun span for known plural noun', () => {
const html = render('- eggs 3');
expect(html).toContain('class="count-noun"');
expect(html).toContain('data-singular="egg"');
expect(html).toContain('data-plural="eggs"');
});
it('displays singular form when count is 1', () => {
const html = render('- onion 1');
expect(html).toMatch(/<span class="count-noun"[^>]*>onion<\/span>/);
});
it('displays plural form when count is not 1', () => {
const html = render('- egg 3');
expect(html).toMatch(/<span class="count-noun"[^>]*>eggs<\/span>/);
});
it('displays plural form when count is 0', () => {
const html = render('- egg 0');
expect(html).toMatch(/<span class="count-noun"[^>]*>eggs<\/span>/);
});
it('uses max value for ranges (plural)', () => {
const html = render('- onion 1-2');
// max value is 2 -> plural
expect(html).toMatch(/<span class="count-noun"[^>]*>onions<\/span>/);
});
it('adds data-has-noun on the count measurement span', () => {
const html = render('- onion 1');
expect(html).toContain('data-has-noun="true"');
});
it('does not add data-has-noun for unknown nouns', () => {
const html = render('- toaster 1');
expect(html).not.toContain('data-has-noun');
expect(html).not.toContain('count-noun');
});
it('handles multi-word nouns like bell pepper', () => {
const html = render('- bell pepper 2');
expect(html).toContain('data-singular="bell pepper"');
expect(html).toContain('data-plural="bell peppers"');
expect(html).toMatch(/<span class="count-noun"[^>]*>bell peppers<\/span>/);
});
it('handles multi-word nouns like egg yolk', () => {
const html = render('- egg yolk 1');
expect(html).toContain('data-singular="egg yolk"');
expect(html).toMatch(/<span class="count-noun"[^>]*>egg yolk<\/span>/);
});
it('corrects plural to singular for count 1', () => {
const html = render('- eggs 1');
expect(html).toMatch(/<span class="count-noun"[^>]*>egg<\/span>/);
});
it('corrects singular to plural for count > 1', () => {
const html = render('- onion 4');
expect(html).toMatch(/<span class="count-noun"[^>]*>onions<\/span>/);
});
it('works in tools section', () => {
const md = [
'## Ingredients',
'- egg 2',
].join('\n');
const html = render(md);
expect(html).toContain('class="count-noun"');
expect(html).toMatch(/<span class="count-noun"[^>]*>eggs<\/span>/);
});
it('preserves text before the noun', () => {
const html = render('- white onion 1');
// "white " should appear before the noun span
expect(html).toMatch(/white <span class="count-noun"/);
});
it('does not generate noun span for non-count measurements', () => {
const html = render('- onion 200g');
expect(html).not.toContain('count-noun');
});
});

View file

@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
const { matchPlural, PLURALS } = require('../plurals');
describe('matchPlural', () => {
it('returns null for empty/null input', () => {
expect(matchPlural('')).toBeNull();
expect(matchPlural(null)).toBeNull();
expect(matchPlural(undefined)).toBeNull();
});
it('returns null for unknown nouns', () => {
expect(matchPlural('dragon fruit ')).toBeNull();
expect(matchPlural('toaster ')).toBeNull();
});
it('matches singular noun at end of text', () => {
const result = matchPlural('onion ');
expect(result).not.toBeNull();
expect(result.singular).toBe('onion');
expect(result.plural).toBe('onions');
});
it('matches plural noun at end of text', () => {
const result = matchPlural('eggs ');
expect(result).not.toBeNull();
expect(result.singular).toBe('egg');
expect(result.plural).toBe('eggs');
});
it('is case-insensitive', () => {
expect(matchPlural('Onion ')).not.toBeNull();
expect(matchPlural('EGGS ')).not.toBeNull();
expect(matchPlural('Tomatoes ')).not.toBeNull();
});
it('trims trailing whitespace', () => {
const result = matchPlural('onion ');
expect(result).not.toBeNull();
expect(result.singular).toBe('onion');
});
it('respects word boundaries', () => {
// "megg" should not match "egg"
expect(matchPlural('megg ')).toBeNull();
// "button" should not match as containing a known noun
expect(matchPlural('button ')).toBeNull();
});
it('matches multi-word nouns (longest first)', () => {
const result = matchPlural('garlic clove ');
expect(result).not.toBeNull();
expect(result.singular).toBe('garlic clove');
expect(result.plural).toBe('garlic cloves');
});
it('matches "clove" when not preceded by "garlic"', () => {
const result = matchPlural('clove ');
expect(result).not.toBeNull();
expect(result.singular).toBe('clove');
expect(result.plural).toBe('cloves');
});
it('matches "bell pepper" before "pepper"', () => {
const result = matchPlural('bell pepper ');
expect(result).not.toBeNull();
expect(result.singular).toBe('bell pepper');
expect(result.plural).toBe('bell peppers');
});
it('matches "pepper" standalone', () => {
const result = matchPlural('pepper ');
expect(result).not.toBeNull();
expect(result.singular).toBe('pepper');
expect(result.plural).toBe('peppers');
});
it('provides correct matchStart and matchLength', () => {
const result = matchPlural('white onion ');
expect(result).not.toBeNull();
expect(result.matchStart).toBe(6); // "white " is 6 chars
expect(result.matchLength).toBe(5); // "onion" is 5 chars
});
it('handles text with prefix before noun', () => {
const result = matchPlural('- [ ] eggs ');
expect(result).not.toBeNull();
expect(result.singular).toBe('egg');
expect(result.plural).toBe('eggs');
});
it('matches egg yolk and egg white', () => {
expect(matchPlural('egg yolk ')).not.toBeNull();
expect(matchPlural('egg yolk ').singular).toBe('egg yolk');
expect(matchPlural('egg white ')).not.toBeNull();
expect(matchPlural('egg white ').singular).toBe('egg white');
});
it('dictionary is sorted longest-first', () => {
for (let i = 0; i < PLURALS.length - 1; i++) {
const currentLen = Math.max(PLURALS[i].singular.length, PLURALS[i].plural.length);
const nextLen = Math.max(PLURALS[i + 1].singular.length, PLURALS[i + 1].plural.length);
expect(currentLen).toBeGreaterThanOrEqual(nextLen);
}
});
});

View file

@ -9,6 +9,7 @@
const { findAllMeasurements, unitType } = require('./matcher');
const { matchDensity } = require('./densities');
const { matchPlural } = require('./plurals');
// ─── Number Formatting ──────────────────────────────────
@ -598,6 +599,19 @@ const SERVING_VESSEL_RE = /\b(glass(?:es)?|mugs?|dish(?:es)?|jars?|containers?|h
// ─── Token Replacement ──────────────────────────────────
function countValueForPlurality(amount) {
if (amount.min !== undefined) return amount.max.value; // range: use max
return amount.value;
}
function applyCasing(original, replacement) {
if (!original || !replacement) return replacement;
if (original[0] === original[0].toUpperCase() && original[0] !== original[0].toLowerCase()) {
return replacement[0].toUpperCase() + replacement.slice(1);
}
return replacement;
}
function replaceMeasurementsInToken(token, Token, inToolsSection) {
const text = token.content;
const measurements = findAllMeasurements(text);
@ -616,8 +630,54 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) {
let lastEnd = 0;
for (const m of measurements) {
// Add text before this measurement
if (m.index > lastEnd) {
// Check for noun before count measurement
let nounMatch = null;
if (m.type === 'count') {
const fullTextBefore = text.slice(0, m.index);
nounMatch = matchPlural(fullTextBefore);
// Only use noun match if it falls within the current segment
if (nounMatch && nounMatch.matchStart < lastEnd) nounMatch = null;
}
if (nounMatch) {
const nounStart = nounMatch.matchStart;
const nounEnd = nounStart + nounMatch.matchLength;
// Prefix text (before the noun)
if (nounStart > lastEnd) {
const prefix = new Token('text', '', 0);
prefix.content = text.slice(lastEnd, nounStart);
newTokens.push(prefix);
}
// Determine correct noun form based on count value
const countVal = countValueForPlurality(m.amount);
const form = countVal === 1 ? nounMatch.singular : nounMatch.plural;
const originalNoun = text.slice(nounStart, nounEnd);
const displayNoun = applyCasing(originalNoun, form);
// Open noun span
const nounOpen = new Token('count_noun_open', 'span', 1);
nounOpen.attrSet('class', 'count-noun');
nounOpen.attrSet('data-singular', nounMatch.singular);
nounOpen.attrSet('data-plural', nounMatch.plural);
newTokens.push(nounOpen);
const nounContent = new Token('text', '', 0);
nounContent.content = displayNoun;
newTokens.push(nounContent);
const nounClose = new Token('count_noun_close', 'span', -1);
newTokens.push(nounClose);
// Trailing space between noun and count
if (nounEnd < m.index) {
const trailing = new Token('text', '', 0);
trailing.content = text.slice(nounEnd, m.index);
newTokens.push(trailing);
}
} else if (m.index > lastEnd) {
// Normal case: just add text before measurement
const before = new Token('text', '', 0);
before.content = text.slice(lastEnd, m.index);
newTokens.push(before);
@ -633,6 +693,11 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) {
open.attrSet('data-alt', altText);
open.attrSet('title', m.match);
// Mark count span as having an adjacent noun
if (nounMatch) {
open.attrSet('data-has-noun', 'true');
}
// Add scaling data for weight/volume/count
const scaleData = computeScaleData(m);
if (scaleData) {

View file

@ -0,0 +1,72 @@
/**
* Noun pluralization for count measurements in recipes.
*
* Each entry has a singular and plural form. Entries are sorted
* longest-first so that "garlic clove" matches before "clove",
* "bell pepper" before "pepper", etc.
*/
const PLURALS = [
{ singular: 'portobello mushroom', plural: 'portobello mushrooms' },
{ singular: 'chile pepper', plural: 'chile peppers' },
{ singular: 'garlic clove', plural: 'garlic cloves' },
{ singular: 'bell pepper', plural: 'bell peppers' },
{ singular: 'egg white', plural: 'egg whites' },
{ singular: 'egg yolk', plural: 'egg yolks' },
{ singular: 'egg yoke', plural: 'egg yokes' },
{ singular: 'tortilla', plural: 'tortillas' },
{ singular: 'mushroom', plural: 'mushrooms' },
{ singular: 'biscuit', plural: 'biscuits' },
{ singular: 'potato', plural: 'potatoes' },
{ singular: 'tomato', plural: 'tomatoes' },
{ singular: 'pepper', plural: 'peppers' },
{ singular: 'hoagie', plural: 'hoagies' },
{ singular: 'banana', plural: 'bananas' },
{ singular: 'carrot', plural: 'carrots' },
{ singular: 'onion', plural: 'onions' },
{ singular: 'lemon', plural: 'lemons' },
{ singular: 'apple', plural: 'apples' },
{ singular: 'clove', plural: 'cloves' },
{ singular: 'lime', plural: 'limes' },
{ singular: 'egg', plural: 'eggs' },
];
/**
* Check if text ends with a known singular or plural noun.
* Case-insensitive, word-boundary-aware (the noun must appear at a
* word boundary in the text).
*
* @param {string} textBefore - text preceding the count measurement
* @returns {{ singular: string, plural: string, matchStart: number, matchLength: number } | null}
*/
function matchPlural(textBefore) {
if (!textBefore) return null;
const trimmed = textBefore.trimEnd();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
for (const entry of PLURALS) {
// Try both singular and plural forms
for (const form of [entry.singular, entry.plural]) {
if (lower.length < form.length) continue;
const tail = lower.slice(-form.length);
if (tail !== form) continue;
// Check word boundary: either start of string or preceded by non-word char
const beforeIdx = lower.length - form.length;
if (beforeIdx > 0 && /\w/.test(lower[beforeIdx - 1])) continue;
return {
singular: entry.singular,
plural: entry.plural,
matchStart: trimmed.length - form.length,
matchLength: form.length,
};
}
}
return null;
}
module.exports = { PLURALS, matchPlural };