feat: added support for adding plurality to ingredients
This commit is contained in:
parent
750bfb912f
commit
8855d6ba19
9 changed files with 468 additions and 8 deletions
|
|
@ -152,6 +152,42 @@
|
||||||
return null;
|
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 ────────────────────────────
|
// ─── Update All Measurements ────────────────────────────
|
||||||
|
|
||||||
function updateAll() {
|
function updateAll() {
|
||||||
|
|
@ -191,7 +227,15 @@
|
||||||
try {
|
try {
|
||||||
var data = JSON.parse(scalableRaw);
|
var data = JSON.parse(scalableRaw);
|
||||||
var text = computeScaledText(data, scale, imperial);
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +244,12 @@
|
||||||
? span.getAttribute(altAttr)
|
? span.getAttribute(altAttr)
|
||||||
: span.getAttribute(defaultAttr);
|
: span.getAttribute(defaultAttr);
|
||||||
if (text) span.textContent = text;
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,4 +518,98 @@ describe('density with approximate values', () => {
|
||||||
expect(weightAttrs.length).toBe(1);
|
expect(weightAttrs.length).toBe(1);
|
||||||
expect(weightAttrs[0].weightDefault).toMatch(/^~/);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
106
lib/measurements/__tests__/plurals.test.js
Normal file
106
lib/measurements/__tests__/plurals.test.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
const { findAllMeasurements, unitType } = require('./matcher');
|
const { findAllMeasurements, unitType } = require('./matcher');
|
||||||
const { matchDensity } = require('./densities');
|
const { matchDensity } = require('./densities');
|
||||||
|
const { matchPlural } = require('./plurals');
|
||||||
|
|
||||||
// ─── Number Formatting ──────────────────────────────────
|
// ─── Number Formatting ──────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -598,6 +599,19 @@ const SERVING_VESSEL_RE = /\b(glass(?:es)?|mugs?|dish(?:es)?|jars?|containers?|h
|
||||||
|
|
||||||
// ─── Token Replacement ──────────────────────────────────
|
// ─── 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) {
|
function replaceMeasurementsInToken(token, Token, inToolsSection) {
|
||||||
const text = token.content;
|
const text = token.content;
|
||||||
const measurements = findAllMeasurements(text);
|
const measurements = findAllMeasurements(text);
|
||||||
|
|
@ -616,8 +630,54 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) {
|
||||||
let lastEnd = 0;
|
let lastEnd = 0;
|
||||||
|
|
||||||
for (const m of measurements) {
|
for (const m of measurements) {
|
||||||
// Add text before this measurement
|
// Check for noun before count measurement
|
||||||
if (m.index > lastEnd) {
|
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);
|
const before = new Token('text', '', 0);
|
||||||
before.content = text.slice(lastEnd, m.index);
|
before.content = text.slice(lastEnd, m.index);
|
||||||
newTokens.push(before);
|
newTokens.push(before);
|
||||||
|
|
@ -633,6 +693,11 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) {
|
||||||
open.attrSet('data-alt', altText);
|
open.attrSet('data-alt', altText);
|
||||||
open.attrSet('title', m.match);
|
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
|
// Add scaling data for weight/volume/count
|
||||||
const scaleData = computeScaleData(m);
|
const scaleData = computeScaleData(m);
|
||||||
if (scaleData) {
|
if (scaleData) {
|
||||||
|
|
|
||||||
72
lib/measurements/plurals.js
Normal file
72
lib/measurements/plurals.js
Normal 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 };
|
||||||
|
|
@ -9,8 +9,8 @@ title: Dahl
|
||||||
- [ ] mustard seeds 1 teaspoon
|
- [ ] mustard seeds 1 teaspoon
|
||||||
- [ ] cumin seeds 2 teaspoon
|
- [ ] cumin seeds 2 teaspoon
|
||||||
- [ ] onion 1
|
- [ ] onion 1
|
||||||
- [ ] garlic 6 clove
|
- [ ] garlic clove 6
|
||||||
- [ ] ginger
|
- [ ] ginger
|
||||||
- [ ] tomato paste 2 tablespoon
|
- [ ] tomato paste 2 tablespoon
|
||||||
- [ ] chili pepper 2 teaspoon
|
- [ ] chili pepper 2 teaspoon
|
||||||
- [ ] turmeric 1 teaspoon
|
- [ ] turmeric 1 teaspoon
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
title: Gin Basil Smash
|
title: Gin Basil Smash
|
||||||
makes: 1 rocks glass
|
makes: 1 lowball
|
||||||
---
|
---
|
||||||
|
|
||||||
### notes
|
### notes
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
title: Gin Sour
|
title: Gin Sour
|
||||||
draft: true
|
draft: true
|
||||||
makes: 1 rocks glass
|
makes: 1 lowball
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ingredients
|
## Ingredients
|
||||||
|
|
@ -17,7 +17,7 @@ makes: 1 rocks glass
|
||||||
- Dry shake vigorously for 15-20 seconds (without ice)
|
- Dry shake vigorously for 15-20 seconds (without ice)
|
||||||
- Add ice to shaker
|
- Add ice to shaker
|
||||||
- Shake vigorously for another 10-15 seconds[^1]
|
- 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
|
- Optional: Add a few drops of Angostura bitters on top of foam
|
||||||
- Garnish with lemon wheel or cherry
|
- Garnish with lemon wheel or cherry
|
||||||
|
|
||||||
|
|
|
||||||
73
scripts/find-missing-plurals.js
Normal file
73
scripts/find-missing-plurals.js
Normal file
|
|
@ -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)`);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue