feat: better supported hybrid units

This commit is contained in:
Leyla Becker 2026-02-22 17:37:00 -06:00
parent 93837aaf20
commit bac7a14f95
4 changed files with 273 additions and 8 deletions

View file

@ -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"');
});
});

View file

@ -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;
module.exports.expandedTime = expandedTime;
module.exports.getHybridVolumeData = getHybridVolumeData;
module.exports.generateHybridVolumeTexts = generateHybridVolumeTexts;