From bac7a14f95efb4c478f4a836fc69a1102ef45a9d Mon Sep 17 00:00:00 2001 From: Leyla Becker Date: Sun, 22 Feb 2026 17:37:00 -0600 Subject: [PATCH] feat: better supported hybrid units --- _includes/base.njk | 14 ++- js/measurements.js | 69 ++++++++++++- lib/measurements/__tests__/plugin.test.js | 79 ++++++++++++++ lib/measurements/plugin.js | 119 +++++++++++++++++++++- 4 files changed, 273 insertions(+), 8 deletions(-) diff --git a/_includes/base.njk b/_includes/base.njk index d59710d..5415cc5 100644 --- a/_includes/base.njk +++ b/_includes/base.njk @@ -141,7 +141,7 @@ gap: var(--space-tight); align-items: center; } - .measurement-toggle-btn, .scale-btn { + .measurement-toggle-btn, .scale-btn, .measure-mode-btn { font-size: var(--font-size-small); padding: 4px 12px; border: 1px solid var(--color-border-default); @@ -151,16 +151,26 @@ cursor: pointer; transition: background-color 0.15s, border-color 0.15s; } - .measurement-toggle-btn:hover, .scale-btn:hover { + .measurement-toggle-btn:hover, .scale-btn:hover, .measure-mode-btn:hover { border-color: var(--color-border-focus); background: var(--color-text-link); color: var(--color-bg-page); } + .measure-mode-btn.active { + background: var(--color-text-link); + color: var(--color-bg-page); + border-color: var(--color-text-link); + } .scale-btn.active { background: var(--color-text-link); color: var(--color-bg-page); border-color: var(--color-text-link); } + .measure-label { + font-size: var(--font-size-small); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + } .scale-label { font-size: var(--font-size-small); color: var(--color-text-muted); diff --git a/js/measurements.js b/js/measurements.js index 24e6df6..13a4079 100644 --- a/js/measurements.js +++ b/js/measurements.js @@ -15,6 +15,7 @@ var showingAlt = {}; var scale = 1; + var measureByVolume = false; var TYPE_LABELS = { temperature: { toAlt: '°F', toDefault: '°C' }, @@ -157,8 +158,17 @@ var spans = document.querySelectorAll('span.measurement'); spans.forEach(function (span) { var type = span.getAttribute('data-measurement-type'); - var imperial = !!showingAlt[type]; - var scalableRaw = span.getAttribute('data-scalable'); + var isHybrid = span.getAttribute('data-hybrid') === 'true'; + var useVolumeMode = isHybrid && measureByVolume; + + var effectiveType = useVolumeMode ? 'volume' : type; + var imperial = !!showingAlt[effectiveType]; + + var scalableAttr = useVolumeMode ? 'data-volume-scalable' : 'data-scalable'; + var defaultAttr = useVolumeMode ? 'data-volume-default' : 'data-default'; + var altAttr = useVolumeMode ? 'data-volume-alt' : 'data-alt'; + + var scalableRaw = span.getAttribute(scalableAttr); if (scalableRaw && scale !== 1) { // Use scaling computation @@ -171,8 +181,8 @@ // No scaling or not scalable — use pre-computed text var text = imperial - ? span.getAttribute('data-alt') - : span.getAttribute('data-default'); + ? span.getAttribute(altAttr) + : span.getAttribute(defaultAttr); if (text) span.textContent = text; }); } @@ -190,6 +200,23 @@ } } + // ─── Measure Mode Toggle ─────────────────────────────── + + function setMeasureMode(mode) { + measureByVolume = (mode === 'volume'); + + var btns = document.querySelectorAll('.measure-mode-btn'); + btns.forEach(function (btn) { + if (btn.getAttribute('data-mode') === mode) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + updateAll(); + } + // ─── Scale Toggle ─────────────────────────────────────── function setScale(newScale) { @@ -219,6 +246,7 @@ // Detect which types have toggleable content var typeHasToggle = {}; var hasScalable = false; + var hasHybrid = false; var spans = document.querySelectorAll('span.measurement'); spans.forEach(function (span) { var type = span.getAttribute('data-measurement-type'); @@ -227,10 +255,43 @@ var alt = span.getAttribute('data-alt'); if (def !== alt) typeHasToggle[type] = true; if (span.getAttribute('data-scalable')) hasScalable = true; + if (span.getAttribute('data-hybrid') === 'true') hasHybrid = true; }); + // Hybrid measurements in volume mode need the volume metric/imperial toggle + if (hasHybrid) { + typeHasToggle['volume'] = true; + } + var hasAny = false; + // Measure mode toggle (Weight vs Volume) — only if hybrid measurements exist + if (hasHybrid) { + hasAny = true; + var measureRow = document.createElement('div'); + measureRow.className = 'toggle-row'; + + var measureLabel = document.createElement('span'); + measureLabel.className = 'measure-label'; + measureLabel.textContent = 'Measure:'; + measureRow.appendChild(measureLabel); + + var modes = [ + { mode: 'weight', label: 'Weight' }, + { mode: 'volume', label: 'Volume' }, + ]; + modes.forEach(function (m) { + var btn = document.createElement('button'); + btn.className = 'measure-mode-btn' + (m.mode === 'weight' ? ' active' : ''); + btn.setAttribute('data-mode', m.mode); + btn.textContent = m.label; + btn.addEventListener('click', function () { setMeasureMode(m.mode); }); + measureRow.appendChild(btn); + }); + + container.appendChild(measureRow); + } + // Unit toggle buttons var unitRow = document.createElement('div'); unitRow.className = 'toggle-row'; diff --git a/lib/measurements/__tests__/plugin.test.js b/lib/measurements/__tests__/plugin.test.js index e08367b..c2a408c 100644 --- a/lib/measurements/__tests__/plugin.test.js +++ b/lib/measurements/__tests__/plugin.test.js @@ -244,4 +244,83 @@ describe('tools section scaling', () => { // Both should exist in countScalables expect(countScalables.length).toBeGreaterThanOrEqual(1); }); +}); + +// ─── Hybrid Weight/Volume Measurements ─────────────────── + +function getHybridAttrs(html) { + const matches = []; + const re = /data-hybrid="true"[^>]*data-volume-default="([^"]*)"[^>]*data-volume-alt="([^"]*)"(?:[^>]*data-volume-scalable="([^"]*)")?/g; + let m; + while ((m = re.exec(html)) !== null) { + matches.push({ + volumeDefault: decodeHtmlEntities(m[1]), + volumeAlt: decodeHtmlEntities(m[2]), + volumeScalable: m[3] ? JSON.parse(decodeHtmlEntities(m[3])) : null, + }); + } + return matches; +} + +describe('hybrid weight/volume measurements', () => { + it('adds hybrid data attributes for weight with volume alt', () => { + const md = '- unsalted butter 80g (6 tablespoons)'; + const html = render(md); + expect(html).toContain('data-hybrid="true"'); + expect(html).toContain('data-volume-default='); + expect(html).toContain('data-volume-alt='); + expect(html).toContain('data-volume-scalable='); + }); + + it('does NOT add hybrid attributes for weight-to-weight alt', () => { + const md = '- butter 227g (8 oz)'; + const html = render(md); + expect(html).not.toContain('data-hybrid'); + }); + + it('generates correct volume texts for simple hybrid', () => { + const md = '- unsalted butter 80g (6 tablespoons)'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + // 6 tbsp = 6 * 14.787 = 88.722ml -> metric: "88.7ml" + expect(hybrids[0].volumeDefault).toBe('88.7ml'); + expect(hybrids[0].volumeAlt).toBe('6 tbsp'); + }); + + it('handles nested hybrid with intermediate', () => { + const md = '- milk 860g (800mL (3 1/3 cups))'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + expect(hybrids[0].volumeDefault).toBe('800ml'); + }); + + it('includes volume scalable data with base in ml', () => { + const md = '- unsalted butter 80g (6 tablespoons)'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + expect(hybrids[0].volumeScalable).toBeDefined(); + expect(hybrids[0].volumeScalable.type).toBe('volume'); + // 6 tablespoons = 6 * 14.787 = 88.722 ml + expect(hybrids[0].volumeScalable.base).toBeCloseTo(88.722, 0); + }); + + it('includes volume scalable data for nested hybrid', () => { + const md = '- milk 860g (800mL (3 1/3 cups))'; + const html = render(md); + const hybrids = getHybridAttrs(html); + expect(hybrids.length).toBe(1); + expect(hybrids[0].volumeScalable.type).toBe('volume'); + expect(hybrids[0].volumeScalable.base).toBeCloseTo(800, 0); + }); + + it('still has weight data attributes alongside hybrid attrs', () => { + const md = '- unsalted butter 80g (6 tablespoons)'; + const html = render(md); + // Should still have the weight default/alt + expect(html).toContain('data-default="80g"'); + expect(html).toContain('data-measurement-type="weight"'); + }); }); \ No newline at end of file diff --git a/lib/measurements/plugin.js b/lib/measurements/plugin.js index 459d427..1d23dfb 100644 --- a/lib/measurements/plugin.js +++ b/lib/measurements/plugin.js @@ -7,7 +7,7 @@ * Toggle: imperial units, expanded times */ -const { findAllMeasurements } = require('./matcher'); +const { findAllMeasurements, unitType } = require('./matcher'); // ─── Number Formatting ────────────────────────────────── @@ -204,6 +204,105 @@ function computeScaleData(measurement) { }; } +// ─── Hybrid (Weight+Volume) Detection ─────────────────── + +function getHybridVolumeData(measurement) { + if (measurement.type !== 'weight') return null; + + const { alt, intermediate } = measurement; + + if (intermediate && alt) { + const intType = unitType(intermediate.unit); + const altType = unitType(alt.unit); + if (intType === 'volume' || altType === 'volume') { + return { + metricSource: intType === 'volume' ? intermediate : alt, + imperialSource: altType === 'volume' ? alt : intermediate, + hasIntermediate: true, + }; + } + return null; + } + + if (alt) { + if (unitType(alt.unit) === 'volume') { + return { metricSource: alt, imperialSource: alt, hasIntermediate: false }; + } + return null; + } + + return null; +} + +function generateHybridVolumeTexts(measurement, hybridData) { + const { metricSource, imperialSource, hasIntermediate } = hybridData; + const approximate = measurement.approximate; + const prefix = approximate ? '~' : ''; + + if (hasIntermediate) { + const metric = toMetricValue(metricSource.amount.value, metricSource.unit); + const imperial = toImperialValue(imperialSource.amount.value, imperialSource.unit); + return { + volumeDefault: prefix + formatValueUnit(metric.value, metric.unit), + volumeAlt: prefix + formatValueUnit(imperial.value, imperial.unit), + }; + } + + const { amount, unit } = metricSource; + + if (amount.min !== undefined) { + const mMin = toMetricValue(amount.min.value, unit); + const mMax = toMetricValue(amount.max.value, unit); + const iMin = toImperialValue(amount.min.value, unit); + const iMax = toImperialValue(amount.max.value, unit); + const rPrefix = (approximate || amount.min.approximate) ? '~' : ''; + + let volumeDefault, volumeAlt; + + if (mMin.unit === mMax.unit) { + const space = NO_SPACE_UNITS.has(mMin.unit) ? '' : ' '; + volumeDefault = rPrefix + formatNumber(mMin.value) + '-' + formatNumber(mMax.value) + space + unitLabel(mMin.unit, mMax.value !== 1); + } else { + volumeDefault = rPrefix + formatValueUnit(mMin.value, mMin.unit) + '-' + formatValueUnit(mMax.value, mMax.unit); + } + + if (iMin.unit === iMax.unit) { + const space = NO_SPACE_UNITS.has(iMin.unit) ? '' : ' '; + volumeAlt = rPrefix + formatNumber(iMin.value) + '-' + formatNumber(iMax.value) + space + unitLabel(iMin.unit, iMax.value !== 1); + } else { + volumeAlt = rPrefix + formatValueUnit(iMin.value, iMin.unit) + '-' + formatValueUnit(iMax.value, iMax.unit); + } + + return { volumeDefault, volumeAlt }; + } + + const metric = toMetricValue(amount.value, unit); + const imperial = toImperialValue(amount.value, unit); + return { + volumeDefault: prefix + formatValueUnit(metric.value, metric.unit), + volumeAlt: prefix + formatValueUnit(imperial.value, imperial.unit), + }; +} + +function computeHybridVolumeScaleData(measurement, hybridData) { + const source = hybridData.metricSource; + const approximate = measurement.approximate; + + if (source.amount.min !== undefined) { + return { + base: [toBaseMl(source.amount.min.value, source.unit), toBaseMl(source.amount.max.value, source.unit)], + type: 'volume', + approx: approximate || source.amount.min.approximate || false, + }; + } + + return { + base: toBaseMl(source.amount.value, source.unit), + type: 'volume', + approx: approximate || false, + }; +} + // ─── Time Formatting ──────────────────────────────────── function toMinutes(value, unit) { @@ -421,6 +520,20 @@ function replaceMeasurementsInToken(token, Token, inToolsSection) { } } + // Add hybrid data attributes for weight measurements with volume alternatives + const hybridData = getHybridVolumeData(m); + if (hybridData) { + const { volumeDefault, volumeAlt } = generateHybridVolumeTexts(m, hybridData); + open.attrSet('data-hybrid', 'true'); + open.attrSet('data-volume-default', volumeDefault); + open.attrSet('data-volume-alt', volumeAlt); + + const volumeScaleData = computeHybridVolumeScaleData(m, hybridData); + if (volumeScaleData) { + open.attrSet('data-volume-scalable', JSON.stringify(volumeScaleData)); + } + } + newTokens.push(open); // Display metric-normalized text by default @@ -505,4 +618,6 @@ module.exports.toMetricValue = toMetricValue; module.exports.toImperialValue = toImperialValue; module.exports.generateTexts = generateTexts; module.exports.collapsedTime = collapsedTime; -module.exports.expandedTime = expandedTime; \ No newline at end of file +module.exports.expandedTime = expandedTime; +module.exports.getHybridVolumeData = getHybridVolumeData; +module.exports.generateHybridVolumeTexts = generateHybridVolumeTexts; \ No newline at end of file