623 lines
No EOL
22 KiB
JavaScript
623 lines
No EOL
22 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, unitType } = 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',
|
|
'week': plural ? 'weeks' : 'week',
|
|
'month': plural ? 'months' : 'month',
|
|
'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,
|
|
};
|
|
}
|
|
|
|
// ─── 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) {
|
|
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 === 'week') return formatValueUnit(value, 'week');
|
|
if (unit === 'month') return formatValueUnit(value, 'month');
|
|
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 === 'week') return formatValueUnit(value, 'week');
|
|
if (unit === 'month') return formatValueUnit(value, 'month');
|
|
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);
|
|
}
|
|
|
|
// ─── Serving Vessel Detection ───────────────────────────
|
|
|
|
// Serving vessel keywords — items in the Tools section containing these words
|
|
// represent containers that hold the finished product, so their counts should
|
|
// scale with the recipe (e.g., doubling a recipe needs twice as many glasses).
|
|
// Cooking tools like pots, pans, bowls, whisks, etc. are NOT in this list
|
|
// because their quantity doesn't change when scaling a recipe.
|
|
const SERVING_VESSEL_RE = /\b(glass(?:es)?|mugs?|dish(?:es)?|jars?|containers?|highballs?|lowballs?|tumblers?|goblets?|snifters?|flutes?|coupes?|ramekins?|pyrex|plates?)\b/i;
|
|
|
|
// ─── Token Replacement ──────────────────────────────────
|
|
|
|
function replaceMeasurementsInToken(token, Token, inToolsSection) {
|
|
const text = token.content;
|
|
const measurements = findAllMeasurements(text);
|
|
|
|
if (measurements.length === 0) return [token];
|
|
|
|
// In the tools section, counts are only scalable if:
|
|
// 1. The line has non-count measurements (dimensions, weights, volumes) alongside
|
|
// the count — e.g. "9x13 inch baking dish 1" or "64 oz mason jar 4"
|
|
// 2. OR the line contains a serving vessel keyword — e.g. "mug 1", "highball 1"
|
|
// Cooking tools like "pots 2" or "mixing bowls 2" have neither, so they stay fixed.
|
|
const hasNonCountMeasurements = measurements.some(m => m.type !== 'count');
|
|
const hasServingVessel = SERVING_VESSEL_RE.test(text);
|
|
|
|
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/count
|
|
const scaleData = computeScaleData(m);
|
|
if (scaleData) {
|
|
// In tools section, skip scaling for bare counts on cooking tools ("pots 2")
|
|
// but allow scaling for serving vessels (keyword match or has measurements)
|
|
const skipScale = inToolsSection && m.type === 'count' && !hasNonCountMeasurements && !hasServingVessel;
|
|
if (!skipScale) {
|
|
open.attrSet('data-scalable', JSON.stringify(scaleData));
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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;
|
|
|
|
let inToolsSection = false;
|
|
let toolsSectionLevel = null;
|
|
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
// Detect heading boundaries to track the "Tools" section.
|
|
// When we see a heading_open, peek at the next inline token for the text.
|
|
if (tokens[i].type === 'heading_open') {
|
|
const level = tokens[i].tag; // 'h1', 'h2', 'h3', etc.
|
|
|
|
// If we're in a tools section and hit a heading at the same or higher level,
|
|
// we've left the tools section.
|
|
if (inToolsSection && level <= toolsSectionLevel) {
|
|
inToolsSection = false;
|
|
toolsSectionLevel = null;
|
|
}
|
|
|
|
// Check if this heading is "Tools" (case-insensitive)
|
|
if (i + 1 < tokens.length && tokens[i + 1].type === 'inline') {
|
|
const headingText = tokens[i + 1].content.trim().toLowerCase();
|
|
if (headingText === 'tools') {
|
|
inToolsSection = true;
|
|
toolsSectionLevel = level;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
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, inToolsSection);
|
|
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;
|
|
module.exports.getHybridVolumeData = getHybridVolumeData;
|
|
module.exports.generateHybridVolumeTexts = generateHybridVolumeTexts; |