diff --git a/lib/measurements/__tests__/plugin.test.js b/lib/measurements/__tests__/plugin.test.js new file mode 100644 index 0000000..e08367b --- /dev/null +++ b/lib/measurements/__tests__/plugin.test.js @@ -0,0 +1,247 @@ +import { describe, it, expect } from 'vitest'; + +const markdownIt = require('markdown-it'); +const measurementPlugin = require('../plugin'); + +function render(md) { + const mdit = markdownIt().use(measurementPlugin); + return mdit.render(md); +} + +function decodeHtmlEntities(str) { + // Build entity strings dynamically to prevent auto-formatter from converting them + const AMP = '&' + 'amp;'; + const LT = '&' + 'lt;'; + const GT = '&' + 'gt;'; + const QUOT = '&' + 'quot;'; + const APOS = '&' + '#39;'; + return str.split(AMP).join('&').split(LT).join('<').split(GT).join('>').split(APOS).join("'").split(QUOT).join('"'); +} + +function getScalableAttrs(html) { + const matches = []; + const re = /data-scalable="([^"]*)"/g; + let m; + while ((m = re.exec(html)) !== null) { + matches.push(JSON.parse(decodeHtmlEntities(m[1]))); + } + return matches; +} + +function hasScalable(html) { + return /data-scalable/.test(html); +} + +// ─── Tools Section: Bare Counts Should NOT Scale ────────── + +describe('tools section scaling', () => { + it('does not make bare counts scalable in Tools section (cooking tools)', () => { + const md = [ + '## Tools', + '- pots 2', + ].join('\n'); + const html = render(md); + // The "2" should be wrapped as a measurement but NOT have data-scalable + expect(html).toContain('class="measurement"'); + expect(hasScalable(html)).toBe(false); + }); + + it('does not make "mixing bowls 2" scalable in Tools section', () => { + const md = [ + '## Tools', + '- mixing bowls 2', + ].join('\n'); + const html = render(md); + expect(html).toContain('class="measurement"'); + expect(hasScalable(html)).toBe(false); + }); + + it('does not make "sauce pan 2" scalable in Tools section', () => { + const md = [ + '## Tools', + '- sauce pan 2', + ].join('\n'); + const html = render(md); + expect(html).toContain('class="measurement"'); + expect(hasScalable(html)).toBe(false); + }); + + // ─── Tools Section: Serving Vessel Keywords SHOULD Scale ── + + it('makes "mug 1" scalable (serving vessel keyword)', () => { + const md = [ + '## Tools', + '- mug 1', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(1); + }); + + it('makes "highball 1" scalable (serving vessel keyword)', () => { + const md = [ + '## Tools', + '- highball 1', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(1); + }); + + it('makes "lowball 1" scalable (serving vessel keyword)', () => { + const md = [ + '## Tools', + '- lowball 1', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(1); + }); + + it('makes "mule glass 1" scalable (serving vessel keyword)', () => { + const md = [ + '## Tools', + '- mule glass 1', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(1); + }); + + // ─── Tools Section: Counts WITH Dimensions SHOULD Scale ── + + it('makes counts scalable when alongside dimensions (serving vessels)', () => { + const md = [ + '## Tools', + '- 9 x 13 inch glass baking dish 1', + ].join('\n'); + const html = render(md); + // Should have scalable data for the count "1" (serving vessel) + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(1); + }); + + it('makes counts scalable for 3D dimensioned vessels', () => { + const md = [ + '## Tools', + '- 8x6x2 inch pyrex dishes 4', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(4); + }); + + it('makes counts scalable for tools with weight measurements', () => { + const md = [ + '## Tools', + '- 64 oz mason jar 4', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(4); + }); + + // ─── Counts OUTSIDE Tools Section Should Still Scale ────── + + it('makes bare counts scalable outside Tools section (ingredients)', () => { + const md = [ + '## Ingredients', + '- eggs 3', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(3); + }); + + it('makes bare counts scalable in Steps section', () => { + const md = [ + '## Steps', + '- use 3 tortillas', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(3); + }); + + // ─── Section Boundary Detection ─────────────────────────── + + it('exits tools section when next heading at same level appears', () => { + const md = [ + '## Tools', + '- pots 2', + '## Steps', + '- use 3 eggs', + ].join('\n'); + const html = render(md); + // "pots 2" count should NOT be scalable (tools section) + // "3" in steps should be scalable + const scalables = getScalableAttrs(html); + const countScalable = scalables.find(s => s.type === 'count'); + expect(countScalable).toBeDefined(); + expect(countScalable.base).toBe(3); + }); + + it('handles Tools as h3 heading', () => { + const md = [ + '### Tools', + '- pots 2', + ].join('\n'); + const html = render(md); + expect(html).toContain('class="measurement"'); + expect(hasScalable(html)).toBe(false); + }); + + // ─── Full Recipe Simulation ─────────────────────────────── + + it('handles a full recipe with ingredients, tools, and steps', () => { + const md = [ + '## Ingredients', + '- onion 1', + '- ground beef 2lbs', + '- cheddar shredded 2 cups', + '## Tools', + '- pan', + '- mixing bowl', + '- 9 x 13 inch glass baking dish 1', + '## Steps', + '- preheat oven to 350 °F', + '- bake for 30 minutes', + ].join('\n'); + const html = render(md); + const scalables = getScalableAttrs(html); + + // Ingredients should have scalable counts and weights/volumes + const weightScalables = scalables.filter(s => s.type === 'weight'); + const volumeScalables = scalables.filter(s => s.type === 'volume'); + expect(weightScalables.length).toBeGreaterThan(0); + expect(volumeScalables.length).toBeGreaterThan(0); + + // The "1" count after "baking dish" in tools should be scalable + // (it's alongside a dimension measurement) + const countScalables = scalables.filter(s => s.type === 'count'); + const dishCount = countScalables.find(s => s.base === 1); + expect(dishCount).toBeDefined(); + + // The ingredient "onion 1" count should also be scalable + // Both should exist in countScalables + expect(countScalables.length).toBeGreaterThanOrEqual(1); + }); +}); \ No newline at end of file diff --git a/lib/measurements/plugin.js b/lib/measurements/plugin.js index ac2debd..82d7a70 100644 --- a/lib/measurements/plugin.js +++ b/lib/measurements/plugin.js @@ -356,14 +356,31 @@ function formatMeasurementText(amount, unit, approximate) { return prefix + formatValueUnit(amount.value, unit); } +// ─── Serving Vessel Detection ─────────────────────────── + +// Serving vessel keywords — items in the Tools section containing these words +// represent containers that hold the finished product, so their counts should +// scale with the recipe (e.g., doubling a recipe needs twice as many glasses). +// Cooking tools like pots, pans, bowls, whisks, etc. are NOT in this list +// because their quantity doesn't change when scaling a recipe. +const SERVING_VESSEL_RE = /\b(glass(?:es)?|mugs?|dish(?:es)?|jars?|containers?|highballs?|lowballs?|tumblers?|goblets?|snifters?|flutes?|coupes?|ramekins?|pyrex|plates?)\b/i; + // ─── Token Replacement ────────────────────────────────── -function replaceMeasurementsInToken(token, Token) { +function replaceMeasurementsInToken(token, Token, inToolsSection) { const text = token.content; const measurements = findAllMeasurements(text); if (measurements.length === 0) return [token]; + // In the tools section, counts are only scalable if: + // 1. The line has non-count measurements (dimensions, weights, volumes) alongside + // the count — e.g. "9x13 inch baking dish 1" or "64 oz mason jar 4" + // 2. OR the line contains a serving vessel keyword — e.g. "mug 1", "highball 1" + // Cooking tools like "pots 2" or "mixing bowls 2" have neither, so they stay fixed. + const hasNonCountMeasurements = measurements.some(m => m.type !== 'count'); + const hasServingVessel = SERVING_VESSEL_RE.test(text); + const newTokens = []; let lastEnd = 0; @@ -385,10 +402,15 @@ function replaceMeasurementsInToken(token, Token) { open.attrSet('data-alt', altText); open.attrSet('title', m.match); - // Add scaling data for weight/volume + // Add scaling data for weight/volume/count const scaleData = computeScaleData(m); if (scaleData) { - open.attrSet('data-scalable', JSON.stringify(scaleData)); + // In tools section, skip scaling for bare counts on cooking tools ("pots 2") + // but allow scaling for serving vessels (keyword match or has measurements) + const skipScale = inToolsSection && m.type === 'count' && !hasNonCountMeasurements && !hasServingVessel; + if (!skipScale) { + open.attrSet('data-scalable', JSON.stringify(scaleData)); + } } newTokens.push(open); @@ -421,7 +443,33 @@ function measurementPlugin(md) { md.core.ruler.push('measurements', function measurementRule(state) { const tokens = state.tokens; + let inToolsSection = false; + let toolsSectionLevel = null; + for (let i = 0; i < tokens.length; i++) { + // Detect heading boundaries to track the "Tools" section. + // When we see a heading_open, peek at the next inline token for the text. + if (tokens[i].type === 'heading_open') { + const level = tokens[i].tag; // 'h1', 'h2', 'h3', etc. + + // If we're in a tools section and hit a heading at the same or higher level, + // we've left the tools section. + if (inToolsSection && level <= toolsSectionLevel) { + inToolsSection = false; + toolsSectionLevel = null; + } + + // Check if this heading is "Tools" (case-insensitive) + if (i + 1 < tokens.length && tokens[i + 1].type === 'inline') { + const headingText = tokens[i + 1].content.trim().toLowerCase(); + if (headingText === 'tools') { + inToolsSection = true; + toolsSectionLevel = level; + } + } + continue; + } + if (tokens[i].type !== 'inline' || !tokens[i].children) continue; const children = tokens[i].children; @@ -429,7 +477,7 @@ function measurementPlugin(md) { for (const child of children) { if (child.type === 'text') { - const replaced = replaceMeasurementsInToken(child, state.Token); + const replaced = replaceMeasurementsInToken(child, state.Token, inToolsSection); newChildren.push(...replaced); } else { newChildren.push(child); diff --git a/recipes/Apple Caramel Upside Down Cake.md b/recipes/Apple Caramel Upside Down Cake.md index d7ed64f..56b605f 100644 --- a/recipes/Apple Caramel Upside Down Cake.md +++ b/recipes/Apple Caramel Upside Down Cake.md @@ -9,7 +9,7 @@ makes: 1 cake - [ ] [Upside Down Cake Batter](/recipe/upside-down-cake-batter/) - [ ] oil for pan ## Tools -- 8 x 8 inch glass baking dish +- 8 x 8 inch glass baking dish 1 - knife ## Steps - preheat oven to 325°f diff --git a/recipes/Blondies.md b/recipes/Blondies.md index 2bd983b..9a73ac2 100644 --- a/recipes/Blondies.md +++ b/recipes/Blondies.md @@ -18,7 +18,7 @@ makes: 1 9x13 pan ## Tools - oven - mixing bowl -- 9 x 13 inch glass baking dish +- 9 x 13 inch glass baking dish 1 - wood spoon ## Steps - mix all ingredients in mixing bowl diff --git a/recipes/Brownies All American.md b/recipes/Brownies All American.md index f6c51b3..3ec1331 100644 --- a/recipes/Brownies All American.md +++ b/recipes/Brownies All American.md @@ -14,7 +14,7 @@ draft: true - [ ] all purpose flour 180g - [ ] chocolate chips 340g ## Tools -- 9 x 13 inch glass baking dish +- 9 x 13 inch glass baking dish 1 ## Steps - 350°F 28 to 32 minutes / 325°F 38 to 42 minutes diff --git a/recipes/Enchiladas.md b/recipes/Enchiladas.md index a184327..856d430 100644 --- a/recipes/Enchiladas.md +++ b/recipes/Enchiladas.md @@ -21,7 +21,7 @@ makes: 1 9x13 pan - [ ] oil to grease pan ## Tools - pan -- 9 x 13 inch glass baking dish +- 9 x 13 inch glass baking dish 1 - cutting board - kitchen knife - garlic press diff --git a/recipes/Lasagna.md b/recipes/Lasagna.md index 9705c8f..bd43ba5 100644 --- a/recipes/Lasagna.md +++ b/recipes/Lasagna.md @@ -18,7 +18,7 @@ makes: 1 9x13 pan - [ ] cold water ## Tools - pasta roller -- 9 x 13 inch glass baking dish +- 9 x 13 inch glass baking dish 1 - pan with lid - pot - mixing bowl diff --git a/recipes/Spice Cake (Gluten Free).md b/recipes/Spice Cake (Gluten Free).md index da4f4fe..3e1525f 100644 --- a/recipes/Spice Cake (Gluten Free).md +++ b/recipes/Spice Cake (Gluten Free).md @@ -18,7 +18,7 @@ makes: 1 8 x 8 cake - [ ] cardamom - [ ] butter for pan ## Tools -- 8 x 8 inch glass baking dish +- 8 x 8 inch glass baking dish 1 - mixing bowls - whisk ## Steps diff --git a/recipes/Stuffing.md b/recipes/Stuffing.md index 65d8f04..76ae82e 100644 --- a/recipes/Stuffing.md +++ b/recipes/Stuffing.md @@ -25,7 +25,7 @@ makes: 1 9x13 pan - [ ] salt ## Tools - Pot -- 9 x 13 inch glass baking dish +- 9 x 13 inch glass baking dish 1 - Bread Knife - Oven - Stove diff --git a/recipes/Tiramisu.md b/recipes/Tiramisu.md index fdcf97b..f64e675 100644 --- a/recipes/Tiramisu.md +++ b/recipes/Tiramisu.md @@ -15,7 +15,7 @@ makes: 1 8 x 8 dish ## Tools - whisk - mixing bowls 3 -- 8 x 8 inch glass baking dish +- 8 x 8 inch glass baking dish 1 - micro plane ## Steps - separate eggs [^1]