Compare commits
2 Commits
63bafc2feb
...
2273dfa0f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2273dfa0f5 | |||
| cf2b3d2e5d |
@@ -1,4 +1,3 @@
|
||||
import path from 'node:path';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
@@ -34,6 +33,8 @@ const LONG_CACHE_EXTENSIONS = new Set([
|
||||
'.otf'
|
||||
]);
|
||||
|
||||
const getExtension = (pathname: string): string => pathname.match(/\.[^./]+$/)?.[0].toLowerCase() ?? '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
const pathname = event.url.pathname;
|
||||
@@ -43,7 +44,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const existing = response.headers.get('content-type');
|
||||
const hasValidHeader = existing && existing.trim().length > 0;
|
||||
if (!hasValidHeader) {
|
||||
const extension = path.extname(pathname).toLowerCase();
|
||||
const extension = getExtension(pathname);
|
||||
const mime = extension && MIME_TYPES[extension];
|
||||
if (mime) {
|
||||
response.headers.set('content-type', mime);
|
||||
@@ -61,7 +62,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
const extension = path.extname(pathname).toLowerCase();
|
||||
const extension = getExtension(pathname);
|
||||
if (LONG_CACHE_EXTENSIONS.has(extension) && !contentType.includes('text/html')) {
|
||||
response.headers.set('cache-control', IMMUTABLE_ASSET_CACHE_CONTROL);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
const manualField = swapState.originalField;
|
||||
const manualValue = swapState.originalValue;
|
||||
const manualValue = swapState.originalValue === null ? '' : String(swapState.originalValue);
|
||||
if (manualField === 1) val1 = manualValue;
|
||||
else val2 = manualValue;
|
||||
swapState = null;
|
||||
@@ -441,6 +441,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
.input-group input[type='number'] {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
? formatConversionValue(offset)
|
||||
: '';
|
||||
$: formulaExpression = supportsExample
|
||||
? `${exampleInput} × ${formattedFactorValue}${hasOffset ? ` + ${formattedOffsetValue}` : ''}`
|
||||
? `${exampleInput} x ${formattedFactorValue}${hasOffset ? ` ${offset > 0 ? '+' : '-'} ${formatConversionValue(Math.abs(offset))}` : ''}`
|
||||
: '';
|
||||
|
||||
$: reverseExampleValue =
|
||||
supportsExample && config.factor !== 0
|
||||
supportsExample && typeof config.factor === 'number' && config.factor !== 0
|
||||
? (1 - offset) / config.factor
|
||||
: null;
|
||||
$: formattedReverseValue = formatConversionValue(reverseExampleValue);
|
||||
@@ -39,12 +39,18 @@
|
||||
{#if supportsExample && result}
|
||||
<section class="example-card">
|
||||
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} {config.labels.in2}
|
||||
</p>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
|
||||
</p>
|
||||
{#if hasOffset}
|
||||
<p class="example-note">
|
||||
Formula: {config.labels.in2} = ({config.labels.in1} x {formattedFactorValue}) {offset > 0 ? '+' : '-'} {formatConversionValue(Math.abs(offset))}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="example-note">
|
||||
1 {config.labels.in1} = {formattedFactorValue} {config.labels.in2}
|
||||
</p>
|
||||
<p class="example-note">
|
||||
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="example-line">
|
||||
Example: convert {exampleInput} {config.labels.in1} to {config.labels.in2}
|
||||
</p>
|
||||
|
||||
@@ -148,7 +148,9 @@
|
||||
if ('addEventListener' in navBreakpoint) {
|
||||
navBreakpoint.addEventListener('change', handleNavChange);
|
||||
} else {
|
||||
navBreakpoint.addListener(handleNavChange);
|
||||
(navBreakpoint as MediaQueryList & {
|
||||
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).addListener(handleNavChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -156,7 +158,9 @@
|
||||
if ('removeEventListener' in navBreakpoint) {
|
||||
navBreakpoint.removeEventListener('change', handleNavChange);
|
||||
} else {
|
||||
navBreakpoint.removeListener(handleNavChange);
|
||||
(navBreakpoint as MediaQueryList & {
|
||||
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).removeListener(handleNavChange);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ export interface CalculatorDef {
|
||||
name: string;
|
||||
category: string;
|
||||
type: string;
|
||||
teaser: string;
|
||||
labels: { in1: string; in2: string };
|
||||
teaser?: string;
|
||||
labels: { in1: string; in2: string; in3?: string };
|
||||
factor?: number;
|
||||
offset?: number;
|
||||
hidden?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// THIS FILE IS AUTO-GENERATED BY migrate.py
|
||||
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w' | 'awg' | 'brinell-rockwell' | 'ev-lux' | 'aov' | 'swg' | 'rockwell-vickers' | 'sus-cst' | 'molarity';
|
||||
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w' | 'awg' | 'awg-swg' | 'brinell-rockwell' | 'cmil-dia' | 'cmil-swg' | 'ev-lux' | 'aov' | 'swg' | 'rockwell-vickers' | 'sus-cst' | 'molarity';
|
||||
|
||||
export interface CalculatorDef {
|
||||
slug: string;
|
||||
@@ -3172,28 +3172,95 @@ const slugIndex: Map<string, CalculatorDef> = new Map(
|
||||
);
|
||||
|
||||
|
||||
const normalizedSlugCounts = calculators.reduce((counts, calc) => {
|
||||
counts.set(calc.slug, (counts.get(calc.slug) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map<string, number>());
|
||||
|
||||
const normalizeText = (value: string): string => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
const labelsText = (calc: CalculatorDef): string =>
|
||||
normalizeText([calc.name, calc.slug, calc.labels.in1, calc.labels.in2, calc.labels.in3 ?? ''].join(' '));
|
||||
|
||||
const categoryRules: Array<[keyof typeof categories, RegExp]> = [
|
||||
['number-systems', /\b(binary|hex|hexadecimal|octal|decimal|ascii|fraction)\b/],
|
||||
['temperature', /\b(celsius|fahrenheit|kelvin|rankine|delisle)\b/],
|
||||
['data', /\b(bit|bits|byte|bytes|nibble|nibbles|baud|kilobyte|megabyte|gigabyte|terabyte|kibibyte|mebibyte|gibibyte|tebibyte)\b/],
|
||||
['electrical', /\b(volt|volts|amp|amps|ampere|amperes|ohm|ohms|siemens|farad|farads|henry|henries|coulomb|coulombs|kva)\b/],
|
||||
['radiation', /\b(becquerel|curie|gray|rad|sievert|rem|roentgen|rutherford|disintegrations)\b/],
|
||||
['light', /\b(lumen|lumens|lux|candela|foot candles?)\b/],
|
||||
['pressure', /\b(pascal|pascals|bar|psi|atmosphere|atmospheres|mmhg|torr|inches of water|inches of mercury|millimeters of mercury|water column)\b/],
|
||||
['power', /\b(watt|watts|kilowatt|kilowatts|megawatt|horsepower|btu hour|btu per hour|calories per second)\b/],
|
||||
['energy', /\b(joule|joules|calorie|calories|kilocalorie|btu|erg|therm|electron volt|watt hour|kilowatt hour)\b/],
|
||||
['force', /\b(force|torque|newton|newtons|dyne|dynes|foot pound|inch pound|pound force|kilogram force)\b/],
|
||||
['volume', /\b(cubic|liter|liters|litre|litres|gallon|gallons|cup|cups|pint|pints|quart|quarts|fluid|milliliter|milliliters|teaspoon|tablespoon|drop|bushel|peck|acre feet|acre foot|drams?)\b/],
|
||||
['area', /\b(square|acre|acres|hectare|hectares|are|ares|barn|barns|section|sections|township|townships)\b/],
|
||||
['speed', /\b(miles per hour|kilometers per hour|meters per second|feet per second|yards per second|knot|knots|mach|rpm|rads|hertz|furlongs per fortnight)\b/],
|
||||
['time', /\b(second|seconds|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years|nanosecond|microsecond|millisecond)\b/],
|
||||
['angle', /\b(degree|degrees|radian|radians|gradian|gradians|arcminute|arcminutes|arcsecond|arcseconds|mils)\b/],
|
||||
['weight', /\b(gram|grams|kilogram|kilograms|pound|pounds|ounce|ounces|carat|carats|stone|slug|ton|tons|pennyweight|grain|grains|momme|dalton|daltons|amu|atomic mass)\b/],
|
||||
['length', /\b(meter|meters|metre|metres|inch|inches|feet|foot|yard|yards|mile|miles|cable|cables|fathom|fathoms|rod|rods|chain|chains|nautical|league|leagues|angstrom|nanometer|centimeter|millimeter|micron)\b/],
|
||||
];
|
||||
|
||||
export function getEffectiveCategory(calc: CalculatorDef): string {
|
||||
const text = labelsText(calc);
|
||||
const match = categoryRules.find(([, pattern]) => pattern.test(text));
|
||||
return match?.[0] ?? calc.category;
|
||||
}
|
||||
|
||||
export function isReliableCalculator(calc: CalculatorDef): boolean {
|
||||
if (calc.type !== 'standard') return true;
|
||||
if (typeof calc.factor === 'number' || typeof calc.offset === 'number') return true;
|
||||
return normalizeText(calc.labels.in1) === normalizeText(calc.labels.in2);
|
||||
}
|
||||
|
||||
export function isIndexableCalculator(calc: CalculatorDef): boolean {
|
||||
return !calc.hidden && isReliableCalculator(calc) && normalizedSlugCounts.get(calc.slug) === 1;
|
||||
}
|
||||
|
||||
export function getCanonicalSlug(calc: CalculatorDef): string {
|
||||
if (!calc.hidden) return calc.slug;
|
||||
|
||||
const parts = calc.slug.match(/^(.*)-to-(.*)$/);
|
||||
if (!parts) return calc.slug;
|
||||
|
||||
const reverseSlug = `${parts[2]}-to-${parts[1]}`;
|
||||
const reverse = slugIndex.get(reverseSlug);
|
||||
return reverse && isIndexableCalculator(reverse) ? reverse.slug : calc.slug;
|
||||
}
|
||||
|
||||
export function getIndexableCalculators(): CalculatorDef[] {
|
||||
const seen = new Set<string>();
|
||||
return calculators.filter((calc) => {
|
||||
if (!isIndexableCalculator(calc)) return false;
|
||||
if (seen.has(calc.slug)) return false;
|
||||
seen.add(calc.slug);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalculatorBySlug(slug: string): CalculatorDef | undefined {
|
||||
return slugIndex.get(slug);
|
||||
}
|
||||
|
||||
export function getCalculatorsByCategory(category: string): CalculatorDef[] {
|
||||
return calculators.filter(c => c.category === category && !c.hidden);
|
||||
return getIndexableCalculators().filter(c => getEffectiveCategory(c) === category);
|
||||
}
|
||||
|
||||
export function getCategoriesWithCounts(): { key: string; label: string; icon: string; count: number }[] {
|
||||
return Object.entries(categories).map(([key, meta]) => ({
|
||||
key,
|
||||
...meta,
|
||||
count: calculators.filter(c => c.category === key && !c.hidden).length,
|
||||
count: getIndexableCalculators().filter(c => getEffectiveCategory(c) === key).length,
|
||||
}));
|
||||
}
|
||||
|
||||
export function searchCalculators(query: string): CalculatorDef[] {
|
||||
const q = query.toLowerCase();
|
||||
return calculators.filter(c =>
|
||||
return getIndexableCalculators().filter(c =>
|
||||
(c.name.toLowerCase().includes(q) ||
|
||||
c.slug.includes(q) ||
|
||||
c.labels.in1.toLowerCase().includes(q) ||
|
||||
c.labels.in2.toLowerCase().includes(q)) && !c.hidden
|
||||
c.labels.in2.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,8 +99,31 @@ const normalizeLabel = (label?: string): string | undefined => {
|
||||
|
||||
const categoryPriority = [...Object.keys(domainDefinitions)];
|
||||
|
||||
const specificDefinitions: Array<[RegExp, string]> = [
|
||||
[/\bwatts?\b|\bkilowatts?\b|\bmegawatts?\b|\bhorsepower\b/i, 'measures the rate at which energy is transferred or converted. It is used for engines, appliances, electrical loads, and heat-transfer rates.'],
|
||||
[/\bcalories?\b|\bjoules?\b|\bbtu\b|\bkilocalories?\b|\btherms?\b|\bwatt hours?\b|\bkilowatt hours?\b/i, 'measures energy, heat, or work. These units are used for food energy, physics calculations, heating systems, and stored electricity.'],
|
||||
[/\bvolts?\b/i, 'measures electric potential difference. Voltage describes the electrical pressure that pushes current through a circuit.'],
|
||||
[/\bamps?\b|\bamperes?\b/i, 'measures electric current. Current describes how much electric charge flows through a circuit each second.'],
|
||||
[/\bohms?\b/i, 'measures electrical resistance. Resistance describes how strongly a component opposes current flow.'],
|
||||
[/\bfarads?\b/i, 'measures capacitance. Capacitance describes how much electric charge a component can store for a given voltage.'],
|
||||
[/\bhenr(y|ies)\b/i, 'measures inductance. Inductance describes how strongly a circuit resists changes in current.'],
|
||||
[/\bpascal|bar|psi|atmosphere|mmhg|torr|inches of water|inches of mercury/i, 'measures pressure, or force applied over an area. Pressure units are common in fluids, gases, weather, vacuum systems, and mechanical specifications.'],
|
||||
[/\bcubic\b|\bliters?\b|\bgallons?\b|\bcups?\b|\bpints?\b|\bquarts?\b|\bfluid\b|\bbushels?\b|\bpecks?\b|\bacre[- ]feet\b|\bacre[- ]foot\b/i, 'measures volume or capacity. Volume units describe liquids, gases, containers, flow totals, and three-dimensional space.'],
|
||||
[/\bsquare\b|\bacres?\b|\bhectares?\b|\bares?\b|\bbarns?\b|\bsections?\b|\btownships?\b/i, 'measures area or surface coverage. Area units are used for land, floors, materials, and two-dimensional spaces.'],
|
||||
[/\bmeters per second\b|\bmiles per hour\b|\bkilometers per hour\b|\bfeet per second\b|\bknots?\b|\bmach\b|\brpm\b/i, 'measures speed, velocity, or rotation rate. These units describe motion over distance or repeated turns over time.'],
|
||||
[/\bseconds?\b|\bminutes?\b|\bhours?\b|\bdays?\b|\bweeks?\b|\bmonths?\b|\byears?\b/i, 'measures time or duration. Time units describe intervals, schedules, rates, and elapsed periods.'],
|
||||
[/\bdegrees?\b|\bradians?\b|\bgradians?\b|\barcminutes?\b|\barcseconds?\b/i, 'measures angle or rotation. Angle units describe turns, bearings, geometry, and circular motion.'],
|
||||
[/\bgrams?\b|\bkilograms?\b|\bpounds?\b|\bounces?\b|\bcarats?\b|\btons?\b|\bdaltons?\b|\bamu\b/i, 'measures mass or weight. These units are used for materials, ingredients, shipping weights, laboratory quantities, and scientific mass scales.'],
|
||||
[/\bbytes?\b|\bbits?\b|\bnibbles?\b|\bkilobytes?\b|\bmegabytes?\b|\bgigabytes?\b|\bterabytes?\b|\bkibibytes?\b|\bmebibytes?\b|\bgibibytes?\b/i, 'measures digital information or storage size. Data units are used for files, memory, transfer sizes, and storage devices.'],
|
||||
[/\bbinary\b|\boctal\b|\bdecimal\b|\bhex\b|\bhexadecimal\b|\bascii\b/i, 'is a representation format for numbers or text. These systems define how values are encoded, displayed, or interpreted.'],
|
||||
[/\bbecquerel|curie|gray|rad|sievert|rem|roentgen|rutherford/i, 'measures radioactivity, absorbed dose, or exposure. Radiation units describe decay rates, ionizing energy, and biological dose effects.'],
|
||||
];
|
||||
|
||||
const buildDefinition = (label: string, categoryKey: string): string => {
|
||||
if (!label) return '';
|
||||
const specific = specificDefinitions.find(([pattern]) => pattern.test(label));
|
||||
if (specific) return `${label} ${specific[1]}`;
|
||||
|
||||
const domain = domainDefinitions[categoryKey] || domainDefinitions.other;
|
||||
const description = [domain.summary, domain.context].filter(Boolean).join(' ');
|
||||
return `${label} ${description}`;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { CalculatorDef } from '$lib/data/calculators';
|
||||
import { categories, getCanonicalSlug, getEffectiveCategory, isIndexableCalculator } from '$lib/data/calculators';
|
||||
|
||||
export const SITE_URL = 'https://howdoyouconvert.com';
|
||||
export const SITE_NAME = 'HowDoYouConvert.com';
|
||||
export const DEFAULT_ROBOTS = 'index,follow';
|
||||
@@ -10,6 +13,8 @@ type SeoInput = {
|
||||
description: string;
|
||||
pathname: string;
|
||||
type?: OpenGraphType;
|
||||
canonical?: string;
|
||||
robots?: string;
|
||||
};
|
||||
|
||||
const normalizePathname = (pathname: string): string => {
|
||||
@@ -23,11 +28,11 @@ const normalizePathname = (pathname: string): string => {
|
||||
|
||||
export const canonicalUrl = (pathname: string): string => `${SITE_URL}${normalizePathname(pathname)}`;
|
||||
|
||||
export const buildSeoMeta = ({ title, description, pathname, type = 'website' }: SeoInput) => {
|
||||
const canonical = canonicalUrl(pathname);
|
||||
export const buildSeoMeta = ({ title, description, pathname, type = 'website', canonical: canonicalOverride, robots = DEFAULT_ROBOTS }: SeoInput) => {
|
||||
const canonical = canonicalOverride ?? canonicalUrl(pathname);
|
||||
return {
|
||||
canonical,
|
||||
robots: DEFAULT_ROBOTS,
|
||||
robots,
|
||||
og: {
|
||||
type,
|
||||
title,
|
||||
@@ -44,3 +49,52 @@ export const buildSeoMeta = ({ title, description, pathname, type = 'website' }:
|
||||
};
|
||||
|
||||
export const toJsonLd = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c');
|
||||
|
||||
export const escapeXml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
export const calculatorDescription = (calc: CalculatorDef): string => {
|
||||
const category = categories[getEffectiveCategory(calc)]?.label ?? 'unit';
|
||||
if (['3col', '3col-mul'].includes(calc.type)) {
|
||||
return `Compute ${calc.labels.in3 ?? 'the derived value'} using ${calc.labels.in1} and ${calc.labels.in2}. Enter any two fields to solve the third.`;
|
||||
}
|
||||
return `Convert ${calc.labels.in1} to ${calc.labels.in2} with a fast ${category.toLowerCase()} calculator. Includes a bidirectional converter, formula notes, examples, and reference values.`;
|
||||
};
|
||||
|
||||
export const calculatorSeo = (calc: CalculatorDef) => {
|
||||
const canonicalSlug = getCanonicalSlug(calc);
|
||||
const noindex = canonicalSlug !== calc.slug || !isIndexableCalculator(calc);
|
||||
const title = `${calc.name} — ${SITE_NAME}`;
|
||||
const description = calculatorDescription(calc);
|
||||
const canonical = canonicalUrl(`/${canonicalSlug}`);
|
||||
|
||||
return {
|
||||
...buildSeoMeta({
|
||||
title,
|
||||
description,
|
||||
pathname: `/${calc.slug}`,
|
||||
canonical,
|
||||
robots: noindex ? 'noindex,follow' : DEFAULT_ROBOTS,
|
||||
}),
|
||||
title,
|
||||
description,
|
||||
canonicalUrl: canonical,
|
||||
noindex,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculatorJsonLd = (calc: CalculatorDef, url: string) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebApplication',
|
||||
name: `${calc.name} Converter`,
|
||||
url,
|
||||
applicationCategory: 'UtilitiesApplication',
|
||||
operatingSystem: 'Any',
|
||||
isAccessibleForFree: true,
|
||||
description: calculatorDescription(calc),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function formatConversionValue(value: number | null | undefined): string {
|
||||
export function formatConversionValue(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return '—';
|
||||
}
|
||||
@@ -21,4 +21,3 @@ function formatConversionValue(value: number | null | undefined): string {
|
||||
const scientific = value.toExponential();
|
||||
return scientific.replace(/\.?0+e/, 'e');
|
||||
}
|
||||
module.exports = { formatConversionValue };
|
||||
|
||||
@@ -166,17 +166,23 @@
|
||||
if ('removeEventListener' in mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', handlePreferenceChange);
|
||||
} else {
|
||||
mediaQuery.removeListener(handlePreferenceChange);
|
||||
(mediaQuery as MediaQueryList & {
|
||||
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).removeListener(handlePreferenceChange);
|
||||
}
|
||||
if ('removeEventListener' in navBreakpoint) {
|
||||
navBreakpoint.removeEventListener('change', handleNavBreakpoint);
|
||||
} else {
|
||||
navBreakpoint.removeListener(handleNavBreakpoint);
|
||||
(navBreakpoint as MediaQueryList & {
|
||||
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).removeListener(handleNavBreakpoint);
|
||||
}
|
||||
if ('removeEventListener' in headerBreakpoint) {
|
||||
headerBreakpoint.removeEventListener('change', handleHeaderBreakpoint);
|
||||
} else {
|
||||
headerBreakpoint.removeListener(handleHeaderBreakpoint);
|
||||
(headerBreakpoint as MediaQueryList & {
|
||||
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).removeListener(handleHeaderBreakpoint);
|
||||
}
|
||||
if (idleCallbackId !== null && typeof appWindow.cancelIdleCallback === 'function') {
|
||||
appWindow.cancelIdleCallback(idleCallbackId);
|
||||
@@ -193,18 +199,24 @@
|
||||
if ('addEventListener' in mediaQuery) {
|
||||
mediaQuery.addEventListener('change', handlePreferenceChange);
|
||||
} else {
|
||||
mediaQuery.addListener(handlePreferenceChange);
|
||||
(mediaQuery as MediaQueryList & {
|
||||
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).addListener(handlePreferenceChange);
|
||||
}
|
||||
|
||||
if ('addEventListener' in navBreakpoint) {
|
||||
navBreakpoint.addEventListener('change', handleNavBreakpoint);
|
||||
} else {
|
||||
navBreakpoint.addListener(handleNavBreakpoint);
|
||||
(navBreakpoint as MediaQueryList & {
|
||||
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).addListener(handleNavBreakpoint);
|
||||
}
|
||||
if ('addEventListener' in headerBreakpoint) {
|
||||
headerBreakpoint.addEventListener('change', handleHeaderBreakpoint);
|
||||
} else {
|
||||
headerBreakpoint.addListener(handleHeaderBreakpoint);
|
||||
(headerBreakpoint as MediaQueryList & {
|
||||
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
|
||||
}).addListener(handleHeaderBreakpoint);
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getCalculatorBySlug, getCalculatorsByCategory, categories } from '$lib/data/calculators';
|
||||
import { getCalculatorBySlug, getCalculatorsByCategory, categories, getEffectiveCategory } from '$lib/data/calculators';
|
||||
import { calculatorJsonLd, calculatorSeo } from '$lib/seo';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
export const load: PageServerLoad = ({ params, setHeaders }) => {
|
||||
const calc = getCalculatorBySlug(params.slug);
|
||||
|
||||
if (!calc) {
|
||||
@@ -11,16 +12,26 @@ export const load: PageServerLoad = ({ params }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get related calculators from the same category (excluding this one)
|
||||
const related = getCalculatorsByCategory(calc.category)
|
||||
const effectiveCategory = getEffectiveCategory(calc);
|
||||
|
||||
const related = getCalculatorsByCategory(effectiveCategory)
|
||||
.filter(c => c.slug !== calc.slug)
|
||||
.slice(0, 8);
|
||||
|
||||
const categoryMeta = categories[calc.category];
|
||||
const categoryMeta = categories[effectiveCategory];
|
||||
const seo = calculatorSeo(calc);
|
||||
if (seo.noindex) {
|
||||
setHeaders({
|
||||
'X-Robots-Tag': 'noindex, follow'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
calculator: calc,
|
||||
related,
|
||||
seo,
|
||||
jsonLd: JSON.stringify(calculatorJsonLd(calc, seo.canonicalUrl)),
|
||||
category: effectiveCategory,
|
||||
categoryLabel: categoryMeta?.label ?? calc.category,
|
||||
categoryIcon: categoryMeta?.icon ?? '🔢'
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Calculator from '$lib/components/Calculator.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
import { buildSeoMeta, canonicalUrl, calculatorDescription, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
$: calc = data.calculator;
|
||||
$: related = data.related;
|
||||
$: pageTitle = `${calc.name} — ${SITE_NAME}`;
|
||||
$: pageDescription = ['3col', '3col-mul'].includes(calc.type)
|
||||
? `Compute ${calc.labels.in3 ?? 'the derived value'} using ${calc.labels.in1} and ${calc.labels.in2}. Enter any two fields to solve the third.`
|
||||
: `Convert ${calc.labels.in1} to ${calc.labels.in2} instantly with our free online calculator. Accurate two-way conversion with the exact formula shown.`;
|
||||
$: pageTitle = data.seo?.title ?? `${calc.name} — ${SITE_NAME}`;
|
||||
$: pageDescription = data.seo?.description ?? calculatorDescription(calc);
|
||||
$: seo = buildSeoMeta({
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
pathname: `/${calc.slug}`,
|
||||
canonical: data.seo?.canonicalUrl,
|
||||
robots: data.seo?.robots,
|
||||
});
|
||||
$: breadcrumbJsonLd = toJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
@@ -30,7 +30,7 @@
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: data.categoryLabel,
|
||||
item: canonicalUrl(`/category/${calc.category}`),
|
||||
item: canonicalUrl(`/category/${data.category}`),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
@@ -75,7 +75,7 @@
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/category/{calc.category}">{data.categoryIcon} {data.categoryLabel}</a></li>
|
||||
<li><a href="/category/{data.category}">{data.categoryIcon} {data.categoryLabel}</a></li>
|
||||
<li aria-current="page">{calc.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -91,18 +91,19 @@
|
||||
{@html calc.descriptionHTML}
|
||||
{:else}
|
||||
<h3>How to convert {calc.labels.in1} to {calc.labels.in2}</h3>
|
||||
{#if calc.type === 'standard' && calc.factor}
|
||||
{#if calc.type === 'standard' && (calc.factor || calc.offset)}
|
||||
<p>
|
||||
The conversion between {calc.labels.in1.toLowerCase()} and {calc.labels.in2.toLowerCase()}
|
||||
uses a fixed multiplication factor. One {calc.labels.in1.toLowerCase().replace(/s$/, '')} equals
|
||||
{calc.factor}{calc.offset ? ` plus an offset of ${calc.offset}` : ''} {calc.labels.in2.toLowerCase()}.
|
||||
uses {calc.offset ? 'a linear formula with an offset' : 'a fixed multiplication factor'}.
|
||||
{#if calc.offset}
|
||||
This offset is common in temperature conversions, where scales differ not just in magnitude but also in their zero point.
|
||||
{:else}
|
||||
One {calc.labels.in1.toLowerCase()} equals {calc.factor} {calc.labels.in2.toLowerCase()}.
|
||||
{/if}
|
||||
</p>
|
||||
<p>
|
||||
To convert, multiply the value in {calc.labels.in1.toLowerCase()} by {calc.factor}{calc.offset ? `, then add ${calc.offset}` : ''}.
|
||||
To convert in the opposite direction, {calc.offset ? `subtract ${calc.offset}, then ` : ''}divide by {calc.factor}.
|
||||
To convert, multiply the value in {calc.labels.in1.toLowerCase()} by {calc.factor ?? 1}{calc.offset ? `, then ${calc.offset > 0 ? 'add' : 'subtract'} ${Math.abs(calc.offset)}` : ''}.
|
||||
To convert in the opposite direction, {calc.offset ? `${calc.offset > 0 ? 'subtract' : 'add'} ${Math.abs(calc.offset)}, then ` : ''}divide by {calc.factor ?? 1}.
|
||||
</p>
|
||||
{:else if calc.type === '3col' || calc.type === '3col-mul'}
|
||||
<p>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getCalculatorsByCategory, categories } from '$lib/data/calculators';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
export const load: PageServerLoad = ({ params, setHeaders }) => {
|
||||
const cat = params.category;
|
||||
const meta = categories[cat];
|
||||
|
||||
@@ -11,6 +11,11 @@ export const load: PageServerLoad = ({ params }) => {
|
||||
}
|
||||
|
||||
const calcs = getCalculatorsByCategory(cat);
|
||||
if (calcs.length === 0) {
|
||||
setHeaders({
|
||||
'X-Robots-Tag': 'noindex, follow'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
category: cat,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
card.style.setProperty('--calc-tooltip-translate', 'calc(-100% - 0.55rem)');
|
||||
};
|
||||
|
||||
const resetCalcTooltipPosition = (event: MouseEvent) => {
|
||||
const resetCalcTooltipPosition = (event: MouseEvent | FocusEvent) => {
|
||||
const card = event.currentTarget as HTMLElement | null;
|
||||
if (!card) return;
|
||||
card.style.removeProperty('--calc-tooltip-left');
|
||||
@@ -33,6 +33,7 @@
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
pathname: categoryPath,
|
||||
robots: data.calculators.length > 0 ? 'index,follow' : 'noindex,follow',
|
||||
});
|
||||
$: breadcrumbJsonLd = toJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { calculators, categories } from '$lib/data/calculators';
|
||||
import { getCategoriesWithCounts, getIndexableCalculators } from '$lib/data/calculators';
|
||||
import { canonicalUrl, escapeXml } from '$lib/seo';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const calculatorUrls = calculators.map(
|
||||
const calculatorUrls = getIndexableCalculators().map(
|
||||
(calc) => ` <url>
|
||||
<loc>https://howdoyouconvert.com/${calc.slug}</loc>
|
||||
<loc>${escapeXml(canonicalUrl(`/${calc.slug}`))}</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>`
|
||||
);
|
||||
|
||||
const categoryUrls = Object.keys(categories).map(
|
||||
const categoryUrls = getCategoriesWithCounts().filter(category => category.count > 0).map(
|
||||
(category) => ` <url>
|
||||
<loc>https://howdoyouconvert.com/category/${category}</loc>
|
||||
<loc>${escapeXml(canonicalUrl(`/category/${category.key}`))}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>`
|
||||
@@ -21,7 +22,7 @@ export const GET: RequestHandler = async () => {
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://howdoyouconvert.com/</loc>
|
||||
<loc>${escapeXml(canonicalUrl('/'))}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
|
||||
Reference in New Issue
Block a user