Hardening responsiveness and SEO

This commit is contained in:
Codex
2026-03-07 23:23:09 +00:00
parent 5a8740722c
commit c1aebbb5e2
16 changed files with 656 additions and 59 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import { afterNavigate } from '$app/navigation';
import { onMount } from 'svelte';
import { page } from '$app/stores';
@@ -253,11 +254,17 @@
];
let sidebarOpen = false;
let headerSearchOpen = false;
let isMobileHeader = false;
let theme: ThemeMode = 'dark';
let selectedPaletteIndex = 0;
$: isHomepage = $page.url.pathname === '/';
$: if (isHomepage && sidebarOpen) {
$: if (isHomepage && (sidebarOpen || headerSearchOpen)) {
sidebarOpen = false;
headerSearchOpen = false;
}
$: if (!isMobileHeader && headerSearchOpen) {
headerSearchOpen = false;
}
const applyPalette = (index: number, persist = false) => {
@@ -289,6 +296,18 @@
applyPalette(index, true);
};
const toggleHeaderSearch = () => {
headerSearchOpen = !headerSearchOpen;
if (headerSearchOpen) {
sidebarOpen = false;
}
};
afterNavigate(() => {
sidebarOpen = false;
headerSearchOpen = false;
});
onMount(() => {
if (!browser) return;
@@ -313,10 +332,30 @@
sidebarOpen = false;
}
};
const headerBreakpoint = window.matchMedia('(max-width: 768px)');
const updateHeaderBreakpoint = (event?: MediaQueryListEvent) => {
const isCompact = event?.matches ?? headerBreakpoint.matches;
isMobileHeader = isCompact;
if (!isCompact) {
headerSearchOpen = false;
}
};
const handleHeaderBreakpoint = (event: MediaQueryListEvent) => {
updateHeaderBreakpoint(event);
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
sidebarOpen = false;
headerSearchOpen = false;
}
};
window.addEventListener('keydown', handleEscape);
if (navBreakpoint.matches) {
sidebarOpen = false;
}
updateHeaderBreakpoint();
const cleanup = () => {
if ('removeEventListener' in mediaQuery) {
@@ -329,6 +368,12 @@
} else {
navBreakpoint.removeListener(handleNavBreakpoint);
}
if ('removeEventListener' in headerBreakpoint) {
headerBreakpoint.removeEventListener('change', handleHeaderBreakpoint);
} else {
headerBreakpoint.removeListener(handleHeaderBreakpoint);
}
window.removeEventListener('keydown', handleEscape);
};
if ('addEventListener' in mediaQuery) {
@@ -342,6 +387,11 @@
} else {
navBreakpoint.addListener(handleNavBreakpoint);
}
if ('addEventListener' in headerBreakpoint) {
headerBreakpoint.addEventListener('change', handleHeaderBreakpoint);
} else {
headerBreakpoint.addListener(handleHeaderBreakpoint);
}
return cleanup;
});
@@ -349,7 +399,7 @@
<svelte:head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
<!-- Matomo Tag Manager -->
<script>
var _mtm = window._mtm = window._mtm || [];
@@ -362,13 +412,20 @@
<!-- End Matomo Tag Manager -->
</svelte:head>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header class="site-header">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div class="header-left">
{#if !isHomepage}
<button
type="button"
class="hamburger"
on:click={() => (sidebarOpen = !sidebarOpen)}
on:click={() => {
sidebarOpen = !sidebarOpen;
if (sidebarOpen) {
headerSearchOpen = false;
}
}}
aria-label="Toggle menu"
aria-controls="site-navigation"
aria-expanded={sidebarOpen ? 'true' : 'false'}
@@ -381,15 +438,35 @@
</a>
</div>
<div class="header-right">
<SearchBar />
{#if !isHomepage}
<div class="desktop-header-search">
<SearchBar idPrefix="header-search" />
</div>
<button
type="button"
class="header-icon-btn search-toggle"
on:click={toggleHeaderSearch}
aria-controls="mobile-header-search"
aria-expanded={headerSearchOpen ? 'true' : 'false'}
aria-label={headerSearchOpen ? 'Close search' : 'Open search'}
>
<span aria-hidden="true">🔍</span>
</button>
{/if}
</div>
</header>
{#if !isHomepage}
<div id="mobile-header-search" class="mobile-header-search" class:open={headerSearchOpen} hidden={!headerSearchOpen}>
<SearchBar idPrefix="mobile-header-search" />
</div>
{/if}
<div class="site-body">
{#if !isHomepage}
<Sidebar bind:open={sidebarOpen} />
{/if}
<main class="main-content">
<main id="main-content" class="main-content" tabindex="-1">
<slot />
</main>
</div>

View File

@@ -2,6 +2,7 @@
import { categories, calculators } from '$lib/data/calculators';
import CategoryCard from '$lib/components/CategoryCard.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import { buildSeoMeta, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
const requiredCategoryFallbacks: Record<string, { label: string; icon: string }> = {
fluids: { label: 'Fluids', icon: '💧' },
@@ -19,18 +20,43 @@
}));
const totalCalculators = calculators.length;
const totalCategories = Object.keys(homepageCategories).length;
const pageTitle = `${SITE_NAME} — Free Unit Conversion Calculators`;
const pageDescription = 'Convert between hundreds of units instantly. Free online calculators for length, weight, temperature, volume, area, speed, energy, power, data and more.';
const seo = buildSeoMeta({
title: pageTitle,
description: pageDescription,
pathname: '/',
});
const websiteJsonLd = toJsonLd({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_NAME,
description: pageDescription,
url: SITE_URL,
});
</script>
<svelte:head>
<title>HowDoYouConvert.com — Free Unit Conversion Calculators</title>
<meta name="description" content="Convert between hundreds of units instantly. Free online calculators for length, weight, temperature, volume, area, speed, energy, power, data and more." />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta name="robots" content={seo.robots} />
<link rel="canonical" href={seo.canonical} />
<meta property="og:type" content={seo.og.type} />
<meta property="og:title" content={seo.og.title} />
<meta property="og:description" content={seo.og.description} />
<meta property="og:url" content={seo.og.url} />
<meta property="og:site_name" content={seo.og.siteName} />
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
<script type="application/ld+json">{websiteJsonLd}</script>
</svelte:head>
<section class="hero">
<h1>How Do You Convert?</h1>
<p>Fast, bidirectional unit conversions with no ads.</p>
<div class="search-center">
<SearchBar />
<SearchBar idPrefix="home-search" />
</div>
</section>

View File

@@ -4,35 +4,95 @@
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
export let data: PageData;
$: calc = data.calculator;
$: related = data.related;
$: pageTitle = `${calc.name}${SITE_NAME}`;
$: pageDescription = `Convert ${calc.labels.in1} to ${calc.labels.in2} instantly with our free online calculator. Accurate bidirectional conversion with the exact formula shown.`;
$: seo = buildSeoMeta({
title: pageTitle,
description: pageDescription,
pathname: `/${calc.slug}`,
});
$: breadcrumbJsonLd = toJsonLd({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: SITE_URL,
},
{
'@type': 'ListItem',
position: 2,
name: data.categoryLabel,
item: canonicalUrl(`/category/${calc.category}`),
},
{
'@type': 'ListItem',
position: 3,
name: calc.name,
item: seo.canonical,
},
],
});
$: webPageJsonLd = toJsonLd({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: calc.name,
description: pageDescription,
url: seo.canonical,
isPartOf: {
'@type': 'WebSite',
name: SITE_NAME,
url: SITE_URL,
},
});
afterNavigate(() => {
if (!browser) return;
window.scrollTo({ top: 0 });
});
onMount(() => {
if (!browser) return;
window.scrollTo({ top: 0 });
return afterNavigate(() => {
window.scrollTo({ top: 0 });
});
});
</script>
<svelte:head>
<title>{calc.name} — HowDoYouConvert.com</title>
<meta name="description" content="Convert {calc.labels.in1} to {calc.labels.in2} instantly with our free online calculator. Accurate bidirectional conversion with the exact formula shown." />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta name="robots" content={seo.robots} />
<link rel="canonical" href={seo.canonical} />
<meta property="og:type" content={seo.og.type} />
<meta property="og:title" content={seo.og.title} />
<meta property="og:description" content={seo.og.description} />
<meta property="og:url" content={seo.og.url} />
<meta property="og:site_name" content={seo.og.siteName} />
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
<script type="application/ld+json">{breadcrumbJsonLd}</script>
<script type="application/ld+json">{webPageJsonLd}</script>
</svelte:head>
<nav class="breadcrumbs">
<a href="/">Home</a>
<span class="sep"></span>
<a href="/category/{calc.category}">{data.categoryIcon} {data.categoryLabel}</a>
<span class="sep"></span>
<span>{calc.name}</span>
<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 aria-current="page">{calc.name}</li>
</ol>
</nav>
<Calculator config={calc} />
<h1 class="page-title calculator-page-title">{calc.name}</h1>
<Calculator config={calc} showTitle={false} />
<div class="seo-content">
{#if calc.descriptionHTML}

View File

@@ -1,18 +1,80 @@
<script lang="ts">
import type { PageData } from './$types';
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
export let data: PageData;
$: pageTitle = `${data.label} Converters — ${SITE_NAME}`;
$: pageDescription = `Browse all ${data.label.toLowerCase()} unit converters. Free online calculators for converting between ${data.label.toLowerCase()} units.`;
$: categoryPath = `/category/${data.category}`;
$: seo = buildSeoMeta({
title: pageTitle,
description: pageDescription,
pathname: categoryPath,
});
$: breadcrumbJsonLd = toJsonLd({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: SITE_URL,
},
{
'@type': 'ListItem',
position: 2,
name: `${data.label} Converters`,
item: seo.canonical,
},
],
});
$: collectionJsonLd = toJsonLd({
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${data.label} Converters`,
description: pageDescription,
url: seo.canonical,
isPartOf: {
'@type': 'WebSite',
name: SITE_NAME,
url: SITE_URL,
},
mainEntity: {
'@type': 'ItemList',
itemListElement: data.calculators.map((calc, index) => ({
'@type': 'ListItem',
position: index + 1,
name: calc.name,
url: canonicalUrl(`/${calc.slug}`),
})),
},
});
</script>
<svelte:head>
<title>{data.label} Converters — HowDoYouConvert.com</title>
<meta name="description" content="Browse all {data.label.toLowerCase()} unit converters. Free online calculators for converting between {data.label.toLowerCase()} units." />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta name="robots" content={seo.robots} />
<link rel="canonical" href={seo.canonical} />
<meta property="og:type" content={seo.og.type} />
<meta property="og:title" content={seo.og.title} />
<meta property="og:description" content={seo.og.description} />
<meta property="og:url" content={seo.og.url} />
<meta property="og:site_name" content={seo.og.siteName} />
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
<script type="application/ld+json">{breadcrumbJsonLd}</script>
<script type="application/ld+json">{collectionJsonLd}</script>
</svelte:head>
<nav class="breadcrumbs">
<a href="/">Home</a>
<span class="sep"></span>
<span>{data.icon} {data.label}</span>
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li aria-current="page">{data.icon} {data.label}</li>
</ol>
</nav>
<h1 class="page-title">{data.icon} {data.label} Converters</h1>