volpe/lib/measurements/plugin.js

367 lines
No EOL
13 KiB
JavaScript

/**
* markdown-it plugin that normalizes measurements to metric at build time
* and wraps them in <span> 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 };
}
}
// ─── 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 };
}
if (type === 'time') {
return generateTimeTexts(amount, unit, approximate);
}
return generateUnitTexts(amount, unit, approximate, type);
}
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);
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;