feat: fixed more tool edge cases
This commit is contained in:
parent
edd4e01c8d
commit
4c7d0fe262
10 changed files with 307 additions and 12 deletions
247
lib/measurements/__tests__/plugin.test.js
Normal file
247
lib/measurements/__tests__/plugin.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -356,14 +356,31 @@ function formatMeasurementText(amount, unit, approximate) {
|
||||||
return prefix + formatValueUnit(amount.value, unit);
|
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 ──────────────────────────────────
|
// ─── Token Replacement ──────────────────────────────────
|
||||||
|
|
||||||
function replaceMeasurementsInToken(token, Token) {
|
function replaceMeasurementsInToken(token, Token, inToolsSection) {
|
||||||
const text = token.content;
|
const text = token.content;
|
||||||
const measurements = findAllMeasurements(text);
|
const measurements = findAllMeasurements(text);
|
||||||
|
|
||||||
if (measurements.length === 0) return [token];
|
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 = [];
|
const newTokens = [];
|
||||||
let lastEnd = 0;
|
let lastEnd = 0;
|
||||||
|
|
||||||
|
|
@ -385,11 +402,16 @@ function replaceMeasurementsInToken(token, Token) {
|
||||||
open.attrSet('data-alt', altText);
|
open.attrSet('data-alt', altText);
|
||||||
open.attrSet('title', m.match);
|
open.attrSet('title', m.match);
|
||||||
|
|
||||||
// Add scaling data for weight/volume
|
// Add scaling data for weight/volume/count
|
||||||
const scaleData = computeScaleData(m);
|
const scaleData = computeScaleData(m);
|
||||||
if (scaleData) {
|
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);
|
newTokens.push(open);
|
||||||
|
|
||||||
|
|
@ -421,7 +443,33 @@ function measurementPlugin(md) {
|
||||||
md.core.ruler.push('measurements', function measurementRule(state) {
|
md.core.ruler.push('measurements', function measurementRule(state) {
|
||||||
const tokens = state.tokens;
|
const tokens = state.tokens;
|
||||||
|
|
||||||
|
let inToolsSection = false;
|
||||||
|
let toolsSectionLevel = null;
|
||||||
|
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
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;
|
if (tokens[i].type !== 'inline' || !tokens[i].children) continue;
|
||||||
|
|
||||||
const children = tokens[i].children;
|
const children = tokens[i].children;
|
||||||
|
|
@ -429,7 +477,7 @@ function measurementPlugin(md) {
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.type === 'text') {
|
if (child.type === 'text') {
|
||||||
const replaced = replaceMeasurementsInToken(child, state.Token);
|
const replaced = replaceMeasurementsInToken(child, state.Token, inToolsSection);
|
||||||
newChildren.push(...replaced);
|
newChildren.push(...replaced);
|
||||||
} else {
|
} else {
|
||||||
newChildren.push(child);
|
newChildren.push(child);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ makes: 1 cake
|
||||||
- [ ] [Upside Down Cake Batter](/recipe/upside-down-cake-batter/)
|
- [ ] [Upside Down Cake Batter](/recipe/upside-down-cake-batter/)
|
||||||
- [ ] oil for pan
|
- [ ] oil for pan
|
||||||
## Tools
|
## Tools
|
||||||
- 8 x 8 inch glass baking dish
|
- 8 x 8 inch glass baking dish 1
|
||||||
- knife
|
- knife
|
||||||
## Steps
|
## Steps
|
||||||
- preheat oven to 325°f
|
- preheat oven to 325°f
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ makes: 1 9x13 pan
|
||||||
## Tools
|
## Tools
|
||||||
- oven
|
- oven
|
||||||
- mixing bowl
|
- mixing bowl
|
||||||
- 9 x 13 inch glass baking dish
|
- 9 x 13 inch glass baking dish 1
|
||||||
- wood spoon
|
- wood spoon
|
||||||
## Steps
|
## Steps
|
||||||
- mix all ingredients in mixing bowl
|
- mix all ingredients in mixing bowl
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ draft: true
|
||||||
- [ ] all purpose flour 180g
|
- [ ] all purpose flour 180g
|
||||||
- [ ] chocolate chips 340g
|
- [ ] chocolate chips 340g
|
||||||
## Tools
|
## Tools
|
||||||
- 9 x 13 inch glass baking dish
|
- 9 x 13 inch glass baking dish 1
|
||||||
## Steps
|
## Steps
|
||||||
- 350°F 28 to 32 minutes / 325°F 38 to 42 minutes
|
- 350°F 28 to 32 minutes / 325°F 38 to 42 minutes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ makes: 1 9x13 pan
|
||||||
- [ ] oil to grease pan
|
- [ ] oil to grease pan
|
||||||
## Tools
|
## Tools
|
||||||
- pan
|
- pan
|
||||||
- 9 x 13 inch glass baking dish
|
- 9 x 13 inch glass baking dish 1
|
||||||
- cutting board
|
- cutting board
|
||||||
- kitchen knife
|
- kitchen knife
|
||||||
- garlic press
|
- garlic press
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ makes: 1 9x13 pan
|
||||||
- [ ] cold water
|
- [ ] cold water
|
||||||
## Tools
|
## Tools
|
||||||
- pasta roller
|
- pasta roller
|
||||||
- 9 x 13 inch glass baking dish
|
- 9 x 13 inch glass baking dish 1
|
||||||
- pan with lid
|
- pan with lid
|
||||||
- pot
|
- pot
|
||||||
- mixing bowl
|
- mixing bowl
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ makes: 1 8 x 8 cake
|
||||||
- [ ] cardamom
|
- [ ] cardamom
|
||||||
- [ ] butter for pan
|
- [ ] butter for pan
|
||||||
## Tools
|
## Tools
|
||||||
- 8 x 8 inch glass baking dish
|
- 8 x 8 inch glass baking dish 1
|
||||||
- mixing bowls
|
- mixing bowls
|
||||||
- whisk
|
- whisk
|
||||||
## Steps
|
## Steps
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ makes: 1 9x13 pan
|
||||||
- [ ] salt
|
- [ ] salt
|
||||||
## Tools
|
## Tools
|
||||||
- Pot
|
- Pot
|
||||||
- 9 x 13 inch glass baking dish
|
- 9 x 13 inch glass baking dish 1
|
||||||
- Bread Knife
|
- Bread Knife
|
||||||
- Oven
|
- Oven
|
||||||
- Stove
|
- Stove
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ makes: 1 8 x 8 dish
|
||||||
## Tools
|
## Tools
|
||||||
- whisk
|
- whisk
|
||||||
- mixing bowls 3
|
- mixing bowls 3
|
||||||
- 8 x 8 inch glass baking dish
|
- 8 x 8 inch glass baking dish 1
|
||||||
- micro plane
|
- micro plane
|
||||||
## Steps
|
## Steps
|
||||||
- separate eggs [^1]
|
- separate eggs [^1]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue