diff --git a/lib/measurements/__tests__/plugin.test.js b/lib/measurements/__tests__/plugin.test.js deleted file mode 100644 index e08367b..0000000 --- a/lib/measurements/__tests__/plugin.test.js +++ /dev/null @@ -1,247 +0,0 @@ -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 82d7a70..ac2debd 100644 --- a/lib/measurements/plugin.js +++ b/lib/measurements/plugin.js @@ -356,31 +356,14 @@ 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, inToolsSection) { +function replaceMeasurementsInToken(token, Token) { 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; @@ -402,15 +385,10 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) { open.attrSet('data-alt', altText); open.attrSet('title', m.match); - // Add scaling data for weight/volume/count + // Add scaling data for weight/volume const scaleData = computeScaleData(m); if (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)); - } + open.attrSet('data-scalable', JSON.stringify(scaleData)); } newTokens.push(open); @@ -443,33 +421,7 @@ 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; @@ -477,7 +429,7 @@ function measurementPlugin(md) { for (const child of children) { if (child.type === 'text') { - const replaced = replaceMeasurementsInToken(child, state.Token, inToolsSection); + const replaced = replaceMeasurementsInToken(child, state.Token); 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 56b605f..d7ed64f 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 1 +- 8 x 8 inch glass baking dish - knife ## Steps - preheat oven to 325°f diff --git a/recipes/Blondies.md b/recipes/Blondies.md index 9a73ac2..2bd983b 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 1 +- 9 x 13 inch glass baking dish - wood spoon ## Steps - mix all ingredients in mixing bowl diff --git a/recipes/Brownies All American.md b/recipes/Brownies All American.md index 3ec1331..f6c51b3 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 1 +- 9 x 13 inch glass baking dish ## Steps - 350°F 28 to 32 minutes / 325°F 38 to 42 minutes diff --git a/recipes/Carbonara.md b/recipes/Carbonara.md index 9aa32a6..a9684e0 100644 --- a/recipes/Carbonara.md +++ b/recipes/Carbonara.md @@ -5,15 +5,15 @@ makes: 4 bowls ## Ingredients - [ ] water 3kg -- [ ] spaghetti[^3] 1 lb +- [ ] spaghetti[3^] 1 lb - [ ] salt 20g -- [ ] guanciale[^1] 4oz -- [ ] pecorino romano[^2] 2oz +- [ ] guanciale[1^] 4oz +- [ ] pecorino romano[2^] 2oz - [ ] eggs 6 (2 whites 6 yokes) - [ ] extra virgin olive oil 6 tablespoons - [ ] fresh ground black pepper several cranks -- [ ] pecorino romano[^2] for garnish -- [ ] fresh ground black pepper for garnish[^4] +- [ ] pecorino romano[2^] for garnish +- [ ] fresh ground black pepper for garnish[4^] ## Tools - pots 2 - fine grater @@ -44,7 +44,7 @@ makes: 4 bowls #recipe/dinner #diet/omnivore -[^1]: Can be substituted with Pancetta or bacon -[^2]: Can be substituted with Parmesan -[^3]: Can be substituted with Rigatoni or Bucatini -[^4]: If your guanciale already has black pepper on it you don't need the extra black pepper +[1^]: Can be substituted with Pancetta or bacon +[2^]: Can be substituted with Parmesan +[3^]: Can be substituted with Rigatoni or Bucatini +[4^]: If your guanciale already has black pepper on it you don't need the extra black pepper \ No newline at end of file diff --git a/recipes/Enchiladas.md b/recipes/Enchiladas.md index 856d430..a184327 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 1 +- 9 x 13 inch glass baking dish - cutting board - kitchen knife - garlic press diff --git a/recipes/Japanese Curry Soup.md b/recipes/Japanese Curry Soup.md index f04bc60..17b713e 100644 --- a/recipes/Japanese Curry Soup.md +++ b/recipes/Japanese Curry Soup.md @@ -11,7 +11,7 @@ title: Japanese Curry Soup - [ ] lion's mane - [ ] vegetable stock 1 jar - [ ] 5 cups water -- [ ] heavy cream [^1] +- [ ] heavy cream [1^] - [ ] 4 japanese curry bricks - [ ] bay leaf - [ ] red pepper @@ -31,4 +31,4 @@ title: Japanese Curry Soup #recipe/dinner/soup #diet/plant_based/optional #diet/vegetarian -[^1]: can use coconut cream +[1^]: can use coconut cream \ No newline at end of file diff --git a/recipes/Lasagna.md b/recipes/Lasagna.md index bd43ba5..9705c8f 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 1 +- 9 x 13 inch glass baking dish - pan with lid - pot - mixing bowl diff --git a/recipes/Mozzarella.md b/recipes/Mozzarella.md index 5c5e1c4..fbf0b6d 100644 --- a/recipes/Mozzarella.md +++ b/recipes/Mozzarella.md @@ -34,4 +34,4 @@ milk powder for a 4th batch is 130g and milk is 3.75 cups #recipe/ingredient #diet/vegetarian -[^1]: chlorine free water works better for this +[1^]: chlorine free water works better for this \ No newline at end of file diff --git a/recipes/Spice Cake (Gluten Free).md b/recipes/Spice Cake (Gluten Free).md index 3e1525f..da4f4fe 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 1 +- 8 x 8 inch glass baking dish - mixing bowls - whisk ## Steps diff --git a/recipes/Stuffing.md b/recipes/Stuffing.md index 76ae82e..65d8f04 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 1 +- 9 x 13 inch glass baking dish - Bread Knife - Oven - Stove diff --git a/recipes/Tiramisu.md b/recipes/Tiramisu.md index f64e675..fdcf97b 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 1 +- 8 x 8 inch glass baking dish - micro plane ## Steps - separate eggs [^1]