452 lines
No EOL
15 KiB
JavaScript
452 lines
No EOL
15 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 };
|
|
}
|
|
}
|
|
|
|
// ─── 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; |