Enhance calculators and SEO features, improve category handling, and update metadata for better indexing

This commit is contained in:
Ben
2026-05-14 17:24:25 -07:00
parent 63bafc2feb
commit cf2b3d2e5d
14 changed files with 242 additions and 55 deletions
+4 -3
View File
@@ -1,4 +1,3 @@
import path from 'node:path';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
@@ -34,6 +33,8 @@ const LONG_CACHE_EXTENSIONS = new Set([
'.otf' '.otf'
]); ]);
const getExtension = (pathname: string): string => pathname.match(/\.[^./]+$/)?.[0].toLowerCase() ?? '';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event); const response = await resolve(event);
const pathname = event.url.pathname; const pathname = event.url.pathname;
@@ -43,7 +44,7 @@ export const handle: Handle = async ({ event, resolve }) => {
const existing = response.headers.get('content-type'); const existing = response.headers.get('content-type');
const hasValidHeader = existing && existing.trim().length > 0; const hasValidHeader = existing && existing.trim().length > 0;
if (!hasValidHeader) { if (!hasValidHeader) {
const extension = path.extname(pathname).toLowerCase(); const extension = getExtension(pathname);
const mime = extension && MIME_TYPES[extension]; const mime = extension && MIME_TYPES[extension];
if (mime) { if (mime) {
response.headers.set('content-type', mime); response.headers.set('content-type', mime);
@@ -61,7 +62,7 @@ export const handle: Handle = async ({ event, resolve }) => {
return response; return response;
} }
const extension = path.extname(pathname).toLowerCase(); const extension = getExtension(pathname);
if (LONG_CACHE_EXTENSIONS.has(extension) && !contentType.includes('text/html')) { if (LONG_CACHE_EXTENSIONS.has(extension) && !contentType.includes('text/html')) {
response.headers.set('cache-control', IMMUTABLE_ASSET_CACHE_CONTROL); response.headers.set('cache-control', IMMUTABLE_ASSET_CACHE_CONTROL);
} }
@@ -68,7 +68,7 @@
} }
const manualField = swapState.originalField; const manualField = swapState.originalField;
const manualValue = swapState.originalValue; const manualValue = swapState.originalValue === null ? '' : String(swapState.originalValue);
if (manualField === 1) val1 = manualValue; if (manualField === 1) val1 = manualValue;
else val2 = manualValue; else val2 = manualValue;
swapState = null; swapState = null;
@@ -441,6 +441,7 @@
margin: 0; margin: 0;
} }
.input-group input[type='number'] { .input-group input[type='number'] {
appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
} }
@@ -26,11 +26,11 @@
? formatConversionValue(offset) ? formatConversionValue(offset)
: ''; : '';
$: formulaExpression = supportsExample $: formulaExpression = supportsExample
? `${exampleInput} × ${formattedFactorValue}${hasOffset ? ` + ${formattedOffsetValue}` : ''}` ? `${exampleInput} x ${formattedFactorValue}${hasOffset ? ` ${offset > 0 ? '+' : '-'} ${formatConversionValue(Math.abs(offset))}` : ''}`
: ''; : '';
$: reverseExampleValue = $: reverseExampleValue =
supportsExample && config.factor !== 0 supportsExample && typeof config.factor === 'number' && config.factor !== 0
? (1 - offset) / config.factor ? (1 - offset) / config.factor
: null; : null;
$: formattedReverseValue = formatConversionValue(reverseExampleValue); $: formattedReverseValue = formatConversionValue(reverseExampleValue);
@@ -39,12 +39,18 @@
{#if supportsExample && result} {#if supportsExample && result}
<section class="example-card"> <section class="example-card">
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3> <h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
{#if hasOffset}
<p class="example-note"> <p class="example-note">
1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} {config.labels.in2} 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>
<p class="example-note"> <p class="example-note">
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1} 1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}
</p> </p>
{/if}
<p class="example-line"> <p class="example-line">
Example: convert {exampleInput} {config.labels.in1} to {config.labels.in2} Example: convert {exampleInput} {config.labels.in1} to {config.labels.in2}
</p> </p>
@@ -148,7 +148,9 @@
if ('addEventListener' in navBreakpoint) { if ('addEventListener' in navBreakpoint) {
navBreakpoint.addEventListener('change', handleNavChange); navBreakpoint.addEventListener('change', handleNavChange);
} else { } else {
navBreakpoint.addListener(handleNavChange); (navBreakpoint as MediaQueryList & {
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).addListener(handleNavChange);
} }
return () => { return () => {
@@ -156,7 +158,9 @@
if ('removeEventListener' in navBreakpoint) { if ('removeEventListener' in navBreakpoint) {
navBreakpoint.removeEventListener('change', handleNavChange); navBreakpoint.removeEventListener('change', handleNavChange);
} else { } else {
navBreakpoint.removeListener(handleNavChange); (navBreakpoint as MediaQueryList & {
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).removeListener(handleNavChange);
} }
}; };
}); });
+2 -2
View File
@@ -7,8 +7,8 @@ export interface CalculatorDef {
name: string; name: string;
category: string; category: string;
type: string; type: string;
teaser: string; teaser?: string;
labels: { in1: string; in2: string }; labels: { in1: string; in2: string; in3?: string };
factor?: number; factor?: number;
offset?: number; offset?: number;
hidden?: boolean; hidden?: boolean;
+72 -5
View File
@@ -1,5 +1,5 @@
// THIS FILE IS AUTO-GENERATED BY migrate.py // 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 { export interface CalculatorDef {
slug: string; 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 { export function getCalculatorBySlug(slug: string): CalculatorDef | undefined {
return slugIndex.get(slug); return slugIndex.get(slug);
} }
export function getCalculatorsByCategory(category: string): CalculatorDef[] { 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 }[] { export function getCategoriesWithCounts(): { key: string; label: string; icon: string; count: number }[] {
return Object.entries(categories).map(([key, meta]) => ({ return Object.entries(categories).map(([key, meta]) => ({
key, key,
...meta, ...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[] { export function searchCalculators(query: string): CalculatorDef[] {
const q = query.toLowerCase(); const q = query.toLowerCase();
return calculators.filter(c => return getIndexableCalculators().filter(c =>
(c.name.toLowerCase().includes(q) || (c.name.toLowerCase().includes(q) ||
c.slug.includes(q) || c.slug.includes(q) ||
c.labels.in1.toLowerCase().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 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 => { const buildDefinition = (label: string, categoryKey: string): string => {
if (!label) return ''; if (!label) return '';
const specific = specificDefinitions.find(([pattern]) => pattern.test(label));
if (specific) return `${label} ${specific[1]}`;
const domain = domainDefinitions[categoryKey] || domainDefinitions.other; const domain = domainDefinitions[categoryKey] || domainDefinitions.other;
const description = [domain.summary, domain.context].filter(Boolean).join(' '); const description = [domain.summary, domain.context].filter(Boolean).join(' ');
return `${label} ${description}`; return `${label} ${description}`;
+57 -3
View File
@@ -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_URL = 'https://howdoyouconvert.com';
export const SITE_NAME = 'HowDoYouConvert.com'; export const SITE_NAME = 'HowDoYouConvert.com';
export const DEFAULT_ROBOTS = 'index,follow'; export const DEFAULT_ROBOTS = 'index,follow';
@@ -10,6 +13,8 @@ type SeoInput = {
description: string; description: string;
pathname: string; pathname: string;
type?: OpenGraphType; type?: OpenGraphType;
canonical?: string;
robots?: string;
}; };
const normalizePathname = (pathname: string): 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 canonicalUrl = (pathname: string): string => `${SITE_URL}${normalizePathname(pathname)}`;
export const buildSeoMeta = ({ title, description, pathname, type = 'website' }: SeoInput) => { export const buildSeoMeta = ({ title, description, pathname, type = 'website', canonical: canonicalOverride, robots = DEFAULT_ROBOTS }: SeoInput) => {
const canonical = canonicalUrl(pathname); const canonical = canonicalOverride ?? canonicalUrl(pathname);
return { return {
canonical, canonical,
robots: DEFAULT_ROBOTS, robots,
og: { og: {
type, type,
title, 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 toJsonLd = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c');
export const escapeXml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
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),
});
+18 -6
View File
@@ -166,17 +166,23 @@
if ('removeEventListener' in mediaQuery) { if ('removeEventListener' in mediaQuery) {
mediaQuery.removeEventListener('change', handlePreferenceChange); mediaQuery.removeEventListener('change', handlePreferenceChange);
} else { } else {
mediaQuery.removeListener(handlePreferenceChange); (mediaQuery as MediaQueryList & {
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).removeListener(handlePreferenceChange);
} }
if ('removeEventListener' in navBreakpoint) { if ('removeEventListener' in navBreakpoint) {
navBreakpoint.removeEventListener('change', handleNavBreakpoint); navBreakpoint.removeEventListener('change', handleNavBreakpoint);
} else { } else {
navBreakpoint.removeListener(handleNavBreakpoint); (navBreakpoint as MediaQueryList & {
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).removeListener(handleNavBreakpoint);
} }
if ('removeEventListener' in headerBreakpoint) { if ('removeEventListener' in headerBreakpoint) {
headerBreakpoint.removeEventListener('change', handleHeaderBreakpoint); headerBreakpoint.removeEventListener('change', handleHeaderBreakpoint);
} else { } else {
headerBreakpoint.removeListener(handleHeaderBreakpoint); (headerBreakpoint as MediaQueryList & {
removeListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).removeListener(handleHeaderBreakpoint);
} }
if (idleCallbackId !== null && typeof appWindow.cancelIdleCallback === 'function') { if (idleCallbackId !== null && typeof appWindow.cancelIdleCallback === 'function') {
appWindow.cancelIdleCallback(idleCallbackId); appWindow.cancelIdleCallback(idleCallbackId);
@@ -193,18 +199,24 @@
if ('addEventListener' in mediaQuery) { if ('addEventListener' in mediaQuery) {
mediaQuery.addEventListener('change', handlePreferenceChange); mediaQuery.addEventListener('change', handlePreferenceChange);
} else { } else {
mediaQuery.addListener(handlePreferenceChange); (mediaQuery as MediaQueryList & {
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).addListener(handlePreferenceChange);
} }
if ('addEventListener' in navBreakpoint) { if ('addEventListener' in navBreakpoint) {
navBreakpoint.addEventListener('change', handleNavBreakpoint); navBreakpoint.addEventListener('change', handleNavBreakpoint);
} else { } else {
navBreakpoint.addListener(handleNavBreakpoint); (navBreakpoint as MediaQueryList & {
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).addListener(handleNavBreakpoint);
} }
if ('addEventListener' in headerBreakpoint) { if ('addEventListener' in headerBreakpoint) {
headerBreakpoint.addEventListener('change', handleHeaderBreakpoint); headerBreakpoint.addEventListener('change', handleHeaderBreakpoint);
} else { } else {
headerBreakpoint.addListener(handleHeaderBreakpoint); (headerBreakpoint as MediaQueryList & {
addListener: (listener: (event: MediaQueryListEvent) => void) => void;
}).addListener(handleHeaderBreakpoint);
} }
return cleanup; return cleanup;
+16 -5
View File
@@ -1,8 +1,9 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; 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); const calc = getCalculatorBySlug(params.slug);
if (!calc) { if (!calc) {
@@ -11,16 +12,26 @@ export const load: PageServerLoad = ({ params }) => {
}); });
} }
// Get related calculators from the same category (excluding this one) const effectiveCategory = getEffectiveCategory(calc);
const related = getCalculatorsByCategory(calc.category)
const related = getCalculatorsByCategory(effectiveCategory)
.filter(c => c.slug !== calc.slug) .filter(c => c.slug !== calc.slug)
.slice(0, 8); .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 { return {
calculator: calc, calculator: calc,
related, related,
seo,
jsonLd: JSON.stringify(calculatorJsonLd(calc, seo.canonicalUrl)),
category: effectiveCategory,
categoryLabel: categoryMeta?.label ?? calc.category, categoryLabel: categoryMeta?.label ?? calc.category,
categoryIcon: categoryMeta?.icon ?? '🔢' categoryIcon: categoryMeta?.icon ?? '🔢'
}; };
+13 -12
View File
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import Calculator from '$lib/components/Calculator.svelte'; import Calculator from '$lib/components/Calculator.svelte';
import type { PageData } from './$types'; 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; export let data: PageData;
$: calc = data.calculator; $: calc = data.calculator;
$: related = data.related; $: related = data.related;
$: pageTitle = `${calc.name}${SITE_NAME}`; $: pageTitle = data.seo?.title ?? `${calc.name}${SITE_NAME}`;
$: pageDescription = ['3col', '3col-mul'].includes(calc.type) $: pageDescription = data.seo?.description ?? calculatorDescription(calc);
? `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.`;
$: seo = buildSeoMeta({ $: seo = buildSeoMeta({
title: pageTitle, title: pageTitle,
description: pageDescription, description: pageDescription,
pathname: `/${calc.slug}`, pathname: `/${calc.slug}`,
canonical: data.seo?.canonicalUrl,
robots: data.seo?.robots,
}); });
$: breadcrumbJsonLd = toJsonLd({ $: breadcrumbJsonLd = toJsonLd({
'@context': 'https://schema.org', '@context': 'https://schema.org',
@@ -30,7 +30,7 @@
'@type': 'ListItem', '@type': 'ListItem',
position: 2, position: 2,
name: data.categoryLabel, name: data.categoryLabel,
item: canonicalUrl(`/category/${calc.category}`), item: canonicalUrl(`/category/${data.category}`),
}, },
{ {
'@type': 'ListItem', '@type': 'ListItem',
@@ -75,7 +75,7 @@
<nav class="breadcrumbs" aria-label="Breadcrumb"> <nav class="breadcrumbs" aria-label="Breadcrumb">
<ol> <ol>
<li><a href="/">Home</a></li> <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> <li aria-current="page">{calc.name}</li>
</ol> </ol>
</nav> </nav>
@@ -91,18 +91,19 @@
{@html calc.descriptionHTML} {@html calc.descriptionHTML}
{:else} {:else}
<h3>How to convert {calc.labels.in1} to {calc.labels.in2}</h3> <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> <p>
The conversion between {calc.labels.in1.toLowerCase()} and {calc.labels.in2.toLowerCase()} 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 uses {calc.offset ? 'a linear formula with an offset' : 'a fixed multiplication factor'}.
{calc.factor}{calc.offset ? ` plus an offset of ${calc.offset}` : ''} {calc.labels.in2.toLowerCase()}.
{#if calc.offset} {#if calc.offset}
This offset is common in temperature conversions, where scales differ not just in magnitude but also in their zero point. 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} {/if}
</p> </p>
<p> <p>
To convert, multiply the value in {calc.labels.in1.toLowerCase()} by {calc.factor}{calc.offset ? `, then add ${calc.offset}` : ''}. 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 ? `subtract ${calc.offset}, then ` : ''}divide by {calc.factor}. To convert in the opposite direction, {calc.offset ? `${calc.offset > 0 ? 'subtract' : 'add'} ${Math.abs(calc.offset)}, then ` : ''}divide by {calc.factor ?? 1}.
</p> </p>
{:else if calc.type === '3col' || calc.type === '3col-mul'} {:else if calc.type === '3col' || calc.type === '3col-mul'}
<p> <p>
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { getCalculatorsByCategory, categories } from '$lib/data/calculators'; import { getCalculatorsByCategory, categories } from '$lib/data/calculators';
export const load: PageServerLoad = ({ params }) => { export const load: PageServerLoad = ({ params, setHeaders }) => {
const cat = params.category; const cat = params.category;
const meta = categories[cat]; const meta = categories[cat];
@@ -11,6 +11,11 @@ export const load: PageServerLoad = ({ params }) => {
} }
const calcs = getCalculatorsByCategory(cat); const calcs = getCalculatorsByCategory(cat);
if (calcs.length === 0) {
setHeaders({
'X-Robots-Tag': 'noindex, follow'
});
}
return { return {
category: cat, category: cat,
@@ -15,7 +15,7 @@
card.style.setProperty('--calc-tooltip-translate', 'calc(-100% - 0.55rem)'); 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; const card = event.currentTarget as HTMLElement | null;
if (!card) return; if (!card) return;
card.style.removeProperty('--calc-tooltip-left'); card.style.removeProperty('--calc-tooltip-left');
@@ -33,6 +33,7 @@
title: pageTitle, title: pageTitle,
description: pageDescription, description: pageDescription,
pathname: categoryPath, pathname: categoryPath,
robots: data.calculators.length > 0 ? 'index,follow' : 'noindex,follow',
}); });
$: breadcrumbJsonLd = toJsonLd({ $: breadcrumbJsonLd = toJsonLd({
'@context': 'https://schema.org', '@context': 'https://schema.org',
@@ -1,18 +1,19 @@
import type { RequestHandler } from './$types'; 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 () => { export const GET: RequestHandler = async () => {
const calculatorUrls = calculators.map( const calculatorUrls = getIndexableCalculators().map(
(calc) => ` <url> (calc) => ` <url>
<loc>https://howdoyouconvert.com/${calc.slug}</loc> <loc>${escapeXml(canonicalUrl(`/${calc.slug}`))}</loc>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url>` </url>`
); );
const categoryUrls = Object.keys(categories).map( const categoryUrls = getCategoriesWithCounts().filter(category => category.count > 0).map(
(category) => ` <url> (category) => ` <url>
<loc>https://howdoyouconvert.com/category/${category}</loc> <loc>${escapeXml(canonicalUrl(`/category/${category.key}`))}</loc>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url>` </url>`
@@ -21,7 +22,7 @@ export const GET: RequestHandler = async () => {
const sitemap = `<?xml version="1.0" encoding="UTF-8"?> const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://howdoyouconvert.com/</loc> <loc>${escapeXml(canonicalUrl('/'))}</loc>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>