/** * markdown-it plugin that normalizes measurements to metric at build time * and wraps them in elements with pre-computed metric/imperial text * for client-side toggling. * * Default display: metric units, collapsed times * Toggle: imperial units, expanded times */ const { findAllMeasurements } = require('./matcher'); // ─── Number Formatting ────────────────────────────────── function formatNumber(n) { if (Number.isInteger(n) || Math.abs(n - Math.round(n)) < 0.001) { return Math.round(n).toString(); } const decimals = Math.abs(n) >= 10 ? 1 : 2; return n.toFixed(decimals).replace(/\.?0+$/, ''); } // ─── Unit Display ─────────────────────────────────────── // Units that get no space before them const NO_SPACE_UNITS = new Set(['g', 'kg', 'ml', '°C', '°F', 'cm', 'mm']); function unitLabel(unit, plural) { const labels = { 'g': 'g', 'kg': 'kg', 'oz': 'oz', 'lb': 'lb', 'cup': plural ? 'cups' : 'cup', 'tablespoon': 'tbsp', 'teaspoon': 'tsp', 'ml': 'ml', 'L': 'L', 'quart': plural ? 'quarts' : 'quart', 'gallon': plural ? 'gallons' : 'gallon', 'pint': plural ? 'pints' : 'pint', 'fl oz': 'fl oz', '°F': '°F', '°C': '°C', 'inch': plural ? 'inches' : 'inch', 'cm': 'cm', 'mm': 'mm', 'minute': 'min', 'hour': 'hr', 'day': plural ? 'days' : 'day', 'second': plural ? 'seconds' : 'sec', 'parts by volume': 'parts by volume', 'parts by weight': 'parts by weight', }; return labels[unit] || unit; } function formatValueUnit(value, unit) { const label = unitLabel(unit, value !== 1); const space = NO_SPACE_UNITS.has(unit) ? '' : ' '; return formatNumber(value) + space + label; } // ─── Conversion Helpers ───────────────────────────────── function roundTemp(v) { return Math.round(v / 5) * 5; } function smartMetricWeight(grams) { if (grams >= 1000) return { value: grams / 1000, unit: 'kg' }; return { value: grams, unit: 'g' }; } function smartImperialWeight(oz) { if (oz >= 16) return { value: oz / 16, unit: 'lb' }; return { value: oz, unit: 'oz' }; } function smartMetricVolume(ml) { if (ml >= 1000) return { value: ml / 1000, unit: 'L' }; return { value: ml, unit: 'ml' }; } function smartImperialVolume(ml) { if (ml >= 236.588) return { value: ml / 236.588, unit: 'cup' }; if (ml >= 14.787) return { value: ml / 14.787, unit: 'tablespoon' }; return { value: ml / 4.929, unit: 'teaspoon' }; } // Convert a single value from one unit to metric function toMetricValue(value, unit) { switch (unit) { // Temperature case '°F': return { value: roundTemp((value - 32) * 5 / 9), unit: '°C' }; case '°C': return { value, unit: '°C' }; // Weight case 'oz': return smartMetricWeight(value * 28.3495); case 'lb': return smartMetricWeight(value * 453.592); case 'g': return smartMetricWeight(value); case 'kg': return smartMetricWeight(value * 1000); // Volume case 'cup': return smartMetricVolume(value * 236.588); case 'tablespoon': return smartMetricVolume(value * 14.787); case 'teaspoon': return smartMetricVolume(value * 4.929); case 'quart': return smartMetricVolume(value * 946.353); case 'gallon': return smartMetricVolume(value * 3785.41); case 'pint': return smartMetricVolume(value * 473.176); case 'fl oz': return smartMetricVolume(value * 29.5735); case 'ml': return smartMetricVolume(value); case 'L': return smartMetricVolume(value * 1000); // Dimension case 'inch': return { value: value * 2.54, unit: 'cm' }; case 'cm': return { value, unit: 'cm' }; case 'mm': return { value, unit: 'mm' }; default: return { value, unit }; } } // Convert a single value from one unit to imperial function toImperialValue(value, unit) { switch (unit) { // Temperature case '°C': return { value: roundTemp(value * 9 / 5 + 32), unit: '°F' }; case '°F': return { value, unit: '°F' }; // Weight case 'g': return smartImperialWeight(value / 28.3495); case 'kg': return smartImperialWeight(value * 2.20462 * 16); // kg→oz then smart case 'oz': return smartImperialWeight(value); case 'lb': return smartImperialWeight(value * 16); // Volume case 'ml': return smartImperialVolume(value); case 'L': return smartImperialVolume(value * 1000); case 'cup': return { value, unit: 'cup' }; case 'tablespoon': return { value, unit: 'tablespoon' }; case 'teaspoon': return { value, unit: 'teaspoon' }; case 'quart': return { value, unit: 'quart' }; case 'gallon': return { value, unit: 'gallon' }; case 'pint': return { value, unit: 'pint' }; case 'fl oz': return { value, unit: 'fl oz' }; // Dimension case 'cm': return { value: value / 2.54, unit: 'inch' }; case 'mm': return { value: value / 25.4, unit: 'inch' }; case 'inch': return { value, unit: 'inch' }; default: return { value, unit }; } } // ─── Base Unit Conversion (for scaling) ───────────────── function toBaseGrams(value, unit) { switch (unit) { case 'g': return value; case 'kg': return value * 1000; case 'oz': return value * 28.3495; case 'lb': return value * 453.592; default: return value; } } function toBaseMl(value, unit) { switch (unit) { case 'ml': return value; case 'L': return value * 1000; case 'cup': return value * 236.588; case 'tablespoon': return value * 14.787; case 'teaspoon': return value * 4.929; case 'quart': return value * 946.353; case 'gallon': return value * 3785.41; case 'pint': return value * 473.176; case 'fl oz': return value * 29.5735; default: return value; } } function computeScaleData(measurement) { const { type, amount, unit, approximate } = measurement; if (unit === 'parts by volume' || unit === 'parts by weight') return null; if (type !== 'weight' && type !== 'volume' && type !== 'count') return null; if (type === 'count') { if (amount.min !== undefined) { return { base: [amount.min.value, amount.max.value], type: 'count', approx: approximate || amount.min.approximate || false, }; } return { base: amount.value, type: 'count', approx: approximate || false, }; } const toBase = type === 'weight' ? toBaseGrams : toBaseMl; if (amount.min !== undefined) { return { base: [toBase(amount.min.value, unit), toBase(amount.max.value, unit)], type: type, approx: approximate || amount.min.approximate || false, }; } return { base: toBase(amount.value, unit), type: type, approx: approximate || false, }; } // ─── Time Formatting ──────────────────────────────────── function toMinutes(value, unit) { if (unit === 'minute') return value; if (unit === 'hour') return value * 60; if (unit === 'second') return value / 60; return null; // days stay as-is } function collapsedTime(value, unit) { if (unit === 'day') return formatValueUnit(value, 'day'); if (unit === 'second') return formatValueUnit(value, 'second'); const mins = toMinutes(value, unit); if (mins === null) return formatValueUnit(value, unit); return formatValueUnit(mins, 'minute'); } function expandedTime(value, unit) { if (unit === 'day') return formatValueUnit(value, 'day'); if (unit === 'second') return formatValueUnit(value, 'second'); const mins = toMinutes(value, unit); if (mins === null) return formatValueUnit(value, unit); if (mins < 60) return formatValueUnit(mins, 'minute'); const hours = Math.floor(mins / 60); const remainder = Math.round(mins % 60); if (remainder === 0) { return hours + (hours === 1 ? ' hour' : ' hours'); } return hours + ' hr ' + remainder + ' min'; } // ─── Text Generation ──────────────────────────────────── function formatSingleConverted(val, unit, approximate) { const prefix = approximate ? '~' : ''; return prefix + formatValueUnit(val, unit); } function generateTexts(measurement) { const { type, amount, unit, approximate } = measurement; // Non-convertible units if (unit === 'parts by volume' || unit === 'parts by weight') { const text = formatMeasurementText(amount, unit, approximate); return { defaultText: text, altText: text }; } // Bare counts — just display the number, no conversion if (type === 'count') { const text = formatCountText(amount, approximate); return { defaultText: text, altText: text }; } if (type === 'time') { return generateTimeTexts(amount, unit, approximate); } return generateUnitTexts(amount, unit, approximate, type); } function formatCountText(amount, approximate) { const prefix = approximate ? '~' : ''; if (amount.min !== undefined) { return prefix + formatNumber(amount.min.value) + '-' + formatNumber(amount.max.value); } return prefix + formatNumber(amount.value); } function generateTimeTexts(amount, unit, approximate) { if (amount.min !== undefined) { const prefix = amount.min.approximate ? '~' : ''; const defText = prefix + collapsedTime(amount.min.value, unit) + '-' + collapsedTime(amount.max.value, unit); const altText = prefix + expandedTime(amount.min.value, unit) + ' to ' + expandedTime(amount.max.value, unit); return { defaultText: defText, altText: altText }; } const prefix = approximate ? '~' : ''; return { defaultText: prefix + collapsedTime(amount.value, unit), altText: prefix + expandedTime(amount.value, unit), }; } function generateUnitTexts(amount, unit, approximate, type) { if (type === 'dimension' && Array.isArray(amount)) { return generateDimensionTexts(amount, unit); } if (amount.min !== undefined) { return generateRangeTexts(amount, unit, type); } // Single value const metric = toMetricValue(amount.value, unit); const imperial = toImperialValue(amount.value, unit); const prefix = approximate ? '~' : ''; return { defaultText: prefix + formatValueUnit(metric.value, metric.unit), altText: prefix + formatValueUnit(imperial.value, imperial.unit), }; } function generateRangeTexts(amount, unit, type) { 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 prefix = amount.min.approximate ? '~' : ''; let defaultText, altText; if (mMin.unit === mMax.unit) { const space = NO_SPACE_UNITS.has(mMin.unit) ? '' : ' '; defaultText = prefix + formatNumber(mMin.value) + '-' + formatNumber(mMax.value) + space + unitLabel(mMin.unit, mMax.value !== 1); } else { defaultText = prefix + formatValueUnit(mMin.value, mMin.unit) + '-' + formatValueUnit(mMax.value, mMax.unit); } if (iMin.unit === iMax.unit) { const space = NO_SPACE_UNITS.has(iMin.unit) ? '' : ' '; altText = prefix + formatNumber(iMin.value) + '-' + formatNumber(iMax.value) + space + unitLabel(iMin.unit, iMax.value !== 1); } else { altText = prefix + formatValueUnit(iMin.value, iMin.unit) + '-' + formatValueUnit(iMax.value, iMax.unit); } return { defaultText, altText }; } function generateDimensionTexts(dims, unit) { const metricDims = dims.map(d => { const c = toMetricValue(d, unit || 'inch'); return c; }); const imperialDims = dims.map(d => { const c = toImperialValue(d, unit || 'inch'); return c; }); const mUnit = metricDims[0].unit; const iUnit = imperialDims[0].unit; const defaultText = metricDims.map(d => formatNumber(d.value)).join('x') + (mUnit ? (NO_SPACE_UNITS.has(mUnit) ? '' : ' ') + unitLabel(mUnit, true) : ''); const altText = imperialDims.map(d => formatNumber(d.value)).join('x') + (iUnit ? (NO_SPACE_UNITS.has(iUnit) ? '' : ' ') + unitLabel(iUnit, true) : ''); return { defaultText, altText }; } function formatMeasurementText(amount, unit, approximate) { const prefix = approximate ? '~' : ''; if (amount.min !== undefined) { return prefix + formatValueUnit(amount.min.value, unit) + '-' + formatValueUnit(amount.max.value, unit); } return prefix + formatValueUnit(amount.value, unit); } // ─── Token Replacement ────────────────────────────────── function replaceMeasurementsInToken(token, Token) { const text = token.content; const measurements = findAllMeasurements(text); if (measurements.length === 0) return [token]; const newTokens = []; let lastEnd = 0; for (const m of measurements) { // Add text before this measurement if (m.index > lastEnd) { const before = new Token('text', '', 0); before.content = text.slice(lastEnd, m.index); newTokens.push(before); } const { defaultText, altText } = generateTexts(m); // Open span const open = new Token('measurement_open', 'span', 1); open.attrSet('class', 'measurement'); open.attrSet('data-measurement-type', m.type); open.attrSet('data-default', defaultText); open.attrSet('data-alt', altText); open.attrSet('title', m.match); // Add scaling data for weight/volume const scaleData = computeScaleData(m); if (scaleData) { open.attrSet('data-scalable', JSON.stringify(scaleData)); } newTokens.push(open); // Display metric-normalized text by default const content = new Token('text', '', 0); content.content = defaultText; newTokens.push(content); // Close span const close = new Token('measurement_close', 'span', -1); newTokens.push(close); lastEnd = m.index + m.match.length; } // Remaining text after last measurement if (lastEnd < text.length) { const after = new Token('text', '', 0); after.content = text.slice(lastEnd); newTokens.push(after); } return newTokens; } // ─── Plugin Entry Point ───────────────────────────────── function measurementPlugin(md) { md.core.ruler.push('measurements', function measurementRule(state) { const tokens = state.tokens; for (let i = 0; i < tokens.length; i++) { if (tokens[i].type !== 'inline' || !tokens[i].children) continue; const children = tokens[i].children; const newChildren = []; for (const child of children) { if (child.type === 'text') { const replaced = replaceMeasurementsInToken(child, state.Token); newChildren.push(...replaced); } else { newChildren.push(child); } } tokens[i].children = newChildren; } }); } module.exports = measurementPlugin; // Exported for testing module.exports.formatNumber = formatNumber; module.exports.toMetricValue = toMetricValue; module.exports.toImperialValue = toImperialValue; module.exports.generateTexts = generateTexts; module.exports.collapsedTime = collapsedTime; module.exports.expandedTime = expandedTime;