diff --git a/js/measurements.js b/js/measurements.js index 6dad318..17d9031 100644 --- a/js/measurements.js +++ b/js/measurements.js @@ -152,6 +152,42 @@ return null; } + // ─── Noun Plurality ───────────────────────────────────── + + function getScaledCountValue(scalableRaw, scaleFactor) { + if (!scalableRaw) return null; + try { + var data = JSON.parse(scalableRaw); + if (data.type !== 'count') return null; + var baseVal = data.base; + if (Array.isArray(baseVal)) return baseVal[1] * scaleFactor; + return baseVal * scaleFactor; + } catch (e) { return null; } + } + + function updateNoun(span, countValue) { + if (span.getAttribute('data-has-noun') !== 'true') return; + var nounSpan = span.previousElementSibling; + // Walk back past whitespace text nodes + if (!nounSpan || !nounSpan.classList || !nounSpan.classList.contains('count-noun')) { + // Try the sibling before the space text node + var prev = span.previousSibling; + while (prev && prev.nodeType === 3) prev = prev.previousSibling; + if (prev && prev.classList && prev.classList.contains('count-noun')) nounSpan = prev; + else return; + } + var singular = nounSpan.getAttribute('data-singular'); + var plural = nounSpan.getAttribute('data-plural'); + if (!singular || !plural) return; + var form = countValue === 1 ? singular : plural; + // Preserve casing from current text + var current = nounSpan.textContent; + if (current && current[0] === current[0].toUpperCase() && current[0] !== current[0].toLowerCase()) { + form = form[0].toUpperCase() + form.slice(1); + } + nounSpan.textContent = form; + } + // ─── Update All Measurements ──────────────────────────── function updateAll() { @@ -191,7 +227,15 @@ try { var data = JSON.parse(scalableRaw); var text = computeScaledText(data, scale, imperial); - if (text) { span.textContent = text; return; } + if (text) { + span.textContent = text; + // Update adjacent noun span for count measurements + if (type === 'count') { + var countVal = getScaledCountValue(scalableRaw, scale); + if (countVal !== null) updateNoun(span, countVal); + } + return; + } } catch (e) {} } @@ -200,6 +244,12 @@ ? span.getAttribute(altAttr) : span.getAttribute(defaultAttr); if (text) span.textContent = text; + + // At 1x scale, restore noun to build-time form + if (type === 'count') { + var countVal = getScaledCountValue(span.getAttribute('data-scalable'), scale); + if (countVal !== null) updateNoun(span, countVal); + } }); } diff --git a/lib/measurements/__tests__/plugin.test.js b/lib/measurements/__tests__/plugin.test.js index 269dcb6..7bb1b58 100644 --- a/lib/measurements/__tests__/plugin.test.js +++ b/lib/measurements/__tests__/plugin.test.js @@ -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(/]*>onion<\/span>/); + }); + + it('displays plural form when count is not 1', () => { + const html = render('- egg 3'); + expect(html).toMatch(/]*>eggs<\/span>/); + }); + + it('displays plural form when count is 0', () => { + const html = render('- egg 0'); + expect(html).toMatch(/]*>eggs<\/span>/); + }); + + it('uses max value for ranges (plural)', () => { + const html = render('- onion 1-2'); + // max value is 2 -> plural + expect(html).toMatch(/]*>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(/]*>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(/]*>egg yolk<\/span>/); + }); + + it('corrects plural to singular for count 1', () => { + const html = render('- eggs 1'); + expect(html).toMatch(/]*>egg<\/span>/); + }); + + it('corrects singular to plural for count > 1', () => { + const html = render('- onion 4'); + expect(html).toMatch(/]*>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(/]*>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 { + const html = render('- onion 200g'); + expect(html).not.toContain('count-noun'); + }); }); \ No newline at end of file diff --git a/lib/measurements/__tests__/plurals.test.js b/lib/measurements/__tests__/plurals.test.js new file mode 100644 index 0000000..9e04ce5 --- /dev/null +++ b/lib/measurements/__tests__/plurals.test.js @@ -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); + } + }); +}); diff --git a/lib/measurements/plugin.js b/lib/measurements/plugin.js index bf33674..2746e1c 100644 --- a/lib/measurements/plugin.js +++ b/lib/measurements/plugin.js @@ -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) { diff --git a/lib/measurements/plurals.js b/lib/measurements/plurals.js new file mode 100644 index 0000000..52d3c51 --- /dev/null +++ b/lib/measurements/plurals.js @@ -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 }; diff --git a/recipes/Dahl.md b/recipes/Dahl.md index f6c3ce1..fc81e03 100644 --- a/recipes/Dahl.md +++ b/recipes/Dahl.md @@ -9,8 +9,8 @@ title: Dahl - [ ] mustard seeds 1 teaspoon - [ ] cumin seeds 2 teaspoon - [ ] onion 1 -- [ ] garlic 6 clove -- [ ] ginger +- [ ] garlic clove 6 +- [ ] ginger - [ ] tomato paste 2 tablespoon - [ ] chili pepper 2 teaspoon - [ ] turmeric 1 teaspoon diff --git a/recipes/Gin Basil Smash.md b/recipes/Gin Basil Smash.md index df89f6e..dea26e6 100644 --- a/recipes/Gin Basil Smash.md +++ b/recipes/Gin Basil Smash.md @@ -1,6 +1,6 @@ --- title: Gin Basil Smash -makes: 1 rocks glass +makes: 1 lowball --- ### notes diff --git a/recipes/Gin Sour (rough draft).md b/recipes/Gin Sour (rough draft).md index fdc2206..8f7935c 100644 --- a/recipes/Gin Sour (rough draft).md +++ b/recipes/Gin Sour (rough draft).md @@ -1,7 +1,7 @@ --- title: Gin Sour draft: true -makes: 1 rocks glass +makes: 1 lowball --- ## Ingredients @@ -17,7 +17,7 @@ makes: 1 rocks glass - Dry shake vigorously for 15-20 seconds (without ice) - Add ice to shaker - Shake vigorously for another 10-15 seconds[^1] -- Strain into rocks glass over fresh ice +- Strain into lowball over fresh ice - Optional: Add a few drops of Angostura bitters on top of foam - Garnish with lemon wheel or cherry diff --git a/scripts/find-missing-plurals.js b/scripts/find-missing-plurals.js new file mode 100644 index 0000000..61ba9a1 --- /dev/null +++ b/scripts/find-missing-plurals.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +/** + * Discovery script: reads all recipe .md files, finds count measurements, + * extracts the preceding word(s), and reports items not in the pluralization dict. + * + * Usage: node scripts/find-missing-plurals.js + */ + +const fs = require('fs'); +const path = require('path'); +const { findAllMeasurements } = require('../lib/measurements/matcher'); +const { matchPlural } = require('../lib/measurements/plurals'); + +const recipesDir = path.join(__dirname, '..', 'recipes'); + +if (!fs.existsSync(recipesDir)) { + console.error('recipes/ directory not found'); + process.exit(1); +} + +const files = fs.readdirSync(recipesDir).filter(f => f.endsWith('.md')); +const missing = new Map(); // noun -> [{ file, line }] + +for (const file of files) { + const content = fs.readFileSync(path.join(recipesDir, file), 'utf8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const measurements = findAllMeasurements(line); + + for (const m of measurements) { + if (m.type !== 'count') continue; + + const textBefore = line.slice(0, m.index); + const result = matchPlural(textBefore); + + if (!result) { + // Extract the last 1-3 words before the count as a candidate noun + const trimmed = textBefore.trimEnd(); + const words = trimmed.split(/\s+/).filter(Boolean); + if (words.length === 0) continue; + + // Skip checkbox markers and list markers + const filtered = words.filter(w => !/^[-*\[\]x]$/.test(w)); + if (filtered.length === 0) continue; + + const candidate = filtered.slice(-2).join(' ').toLowerCase(); + if (!candidate || /^\d/.test(candidate)) continue; + + if (!missing.has(candidate)) { + missing.set(candidate, []); + } + missing.get(candidate).push({ file, line: i + 1 }); + } + } + } +} + +if (missing.size === 0) { + console.log('All count nouns are covered by the pluralization dictionary.'); +} else { + console.log('Count measurements with unrecognized preceding nouns:\n'); + const sorted = [...missing.entries()].sort((a, b) => b[1].length - a[1].length); + for (const [noun, locations] of sorted) { + console.log(` "${noun}" (${locations.length} occurrence${locations.length > 1 ? 's' : ''})`); + for (const loc of locations) { + console.log(` - ${loc.file}:${loc.line}`); + } + } + console.log(`\nTotal: ${missing.size} unrecognized noun(s)`); +}