Compare commits

...

5 Commits

20 changed files with 286 additions and 76 deletions
+2 -2
View File
@@ -4,9 +4,9 @@
"FROM node:20",
"WORKDIR /app",
"COPY hdyc-svelte/package*.json ./",
"RUN npm install",
"RUN npm ci",
"COPY hdyc-svelte/ .",
"RUN npm run build",
"CMD [\"npm\", \"run\", \"start\"]"
"CMD [\"node\", \"build\"]"
]
}
+8 -4
View File
@@ -1,4 +1,3 @@
import path from 'node:path';
import type { Handle } from '@sveltejs/kit';
const MIME_TYPES: Record<string, string> = {
@@ -13,7 +12,8 @@ const MIME_TYPES: Record<string, string> = {
'.otf': 'font/otf'
};
const HTML_CACHE_CONTROL = 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400';
const HTML_CACHE_CONTROL = 'no-cache, max-age=0, must-revalidate, no-transform';
const EDGE_HTML_CACHE_CONTROL = 'max-age=300, stale-while-revalidate=86400';
const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const ASSET_404_CACHE_CONTROL = 'no-store';
const LONG_CACHE_EXTENSIONS = new Set([
@@ -34,6 +34,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 +45,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 +63,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);
}
@@ -70,6 +72,8 @@ export const handle: Handle = async ({ event, resolve }) => {
// bundle hashes after each deployment.
if (contentType.includes('text/html')) {
response.headers.set('cache-control', HTML_CACHE_CONTROL);
response.headers.set('cdn-cache-control', EDGE_HTML_CACHE_CONTROL);
response.headers.set('cloudflare-cdn-cache-control', EDGE_HTML_CACHE_CONTROL);
}
return response;
@@ -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);
@@ -42,9 +42,11 @@
<p class="example-note">
1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} {config.labels.in2}
</p>
{#if !hasOffset}
<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>
@@ -1,20 +1,18 @@
<script lang="ts">
import { getDefinition } from '$lib/data/unitDefinitions';
import type { CalculatorDef } from '$lib/data/calculatorLoader';
import { getDefinitionSync } from '$lib/data/unitDefinitions';
import type { CalculatorDef } from '$lib/data/calculators';
export let config: CalculatorDef;
let label1 = 'Unit 1';
let label2 = 'Unit 2';
let def1: string | undefined;
let def2: string | undefined;
let def1 = '';
let def2 = '';
$: label1 = config.labels.in1 || 'Unit 1';
$: label2 = config.labels.in2 || 'Unit 2';
$: {
getDefinition(label1, config.category).then(d => { def1 = d; });
getDefinition(label2, config.category).then(d => { def2 = d; });
}
$: def1 = getDefinitionSync(label1, config.category) ?? '';
$: def2 = getDefinitionSync(label2, config.category) ?? '';
</script>
<section class="definition-card">
@@ -22,11 +20,11 @@
<div class="definition-grid">
<article>
<strong>{label1}</strong>
<p>{def1 ?? `Definition pending for ${label1}.`}</p>
<p>{def1}</p>
</article>
<article>
<strong>{label2}</strong>
<p>{def2 ?? `Definition pending for ${label2}.`}</p>
<p>{def2}</p>
</article>
</div>
</section>
@@ -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);
}
};
});
+2 -2
View File
@@ -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;
+72 -5
View File
@@ -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,34 @@ const normalizeLabel = (label?: string): string | undefined => {
const categoryPriority = [...Object.keys(domainDefinitions)];
const specificDefinitions: Array<[RegExp, string]> = [
[/\btons? of tnt\b/i, 'measures explosive energy release. One ton of TNT is conventionally defined as 4.184 gigajoules of energy.'],
[/\batomic time units?\b/i, 'measures time on an atomic scale. One atomic unit of time is about 2.418884326505e-17 seconds.'],
[/\bastronomical units?\b/i, 'measures large distances in astronomy. One astronomical unit is the average Earth-Sun distance, exactly 149,597,870,700 meters.'],
[/\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}`;
@@ -154,3 +180,9 @@ export async function getDefinition(label: string, category?: string): Promise<s
const entries = defs[normalized];
return findByPriority(entries, category);
}
export function getDefinitionSync(label: string, category?: string): string | undefined {
const normalized = normalizeLabel(label);
if (!normalized) return undefined;
return buildDefinition(normalized, category || 'other');
}
+58 -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_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,53 @@ 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, '&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,
canonicalSlug,
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 };
+18 -6
View File
@@ -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 -3
View File
@@ -1,3 +1 @@
// Prerender the homepage as static HTML at build time.
// adapter-node will serve this as a static file — no SSR round-trip.
export const prerender = true;
export const prerender = false;
+21 -6
View File
@@ -1,8 +1,9 @@
import { error } from '@sveltejs/kit';
import { error, redirect } 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, toJsonLd } from '$lib/seo';
export const load: PageServerLoad = ({ params }) => {
export const load: PageServerLoad = ({ params, setHeaders }) => {
const calc = getCalculatorBySlug(params.slug);
if (!calc) {
@@ -11,16 +12,30 @@ 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 seo = calculatorSeo(calc);
if (seo.canonicalSlug !== calc.slug) {
throw redirect(301, `/${seo.canonicalSlug}`);
}
const related = getCalculatorsByCategory(effectiveCategory)
.filter(c => c.slug !== calc.slug)
.slice(0, 8);
const categoryMeta = categories[calc.category];
const categoryMeta = categories[effectiveCategory];
if (seo.noindex) {
setHeaders({
'X-Robots-Tag': 'noindex, follow'
});
}
return {
calculator: calc,
related,
seo,
jsonLd: toJsonLd(calculatorJsonLd(calc, seo.canonicalUrl)),
category: effectiveCategory,
categoryLabel: categoryMeta?.label ?? calc.category,
categoryIcon: categoryMeta?.icon ?? '🔢'
};
+14 -12
View File
@@ -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',
@@ -68,6 +68,7 @@
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
{@html `<script type="application/ld+json">${data.jsonLd}</script>`}
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
{@html `<script type="application/ld+json">${webPageJsonLd}</script>`}
</svelte:head>
@@ -75,7 +76,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 +92,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',
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
const robots = `User-agent: *
Disallow:
Sitemap: https://howdoyouconvert.com/sitemap.xml
`;
export const GET: RequestHandler = () =>
new Response(robots, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, max-age=0, must-revalidate, no-transform',
'CDN-Cache-Control': 'max-age=300, stale-while-revalidate=86400',
'Cloudflare-CDN-Cache-Control': 'max-age=300, stale-while-revalidate=86400'
}
});
+10 -7
View File
@@ -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>
@@ -32,7 +33,9 @@ ${calculatorUrls.join('\n')}
return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'max-age=0, s-maxage=3600'
'Cache-Control': 'no-cache, max-age=0, must-revalidate, no-transform',
'CDN-Cache-Control': 'max-age=300, stale-while-revalidate=86400',
'Cloudflare-CDN-Cache-Control': 'max-age=300, stale-while-revalidate=86400'
}
});
};
-4
View File
@@ -1,4 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:
Sitemap: https://howdoyouconvert.com/sitemap.xml