feat: added unit conversion buttons
This commit is contained in:
parent
12df111c5e
commit
a96734c394
10 changed files with 2624 additions and 1 deletions
367
lib/measurements/plugin.js
Normal file
367
lib/measurements/plugin.js
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue