Files
voculinux/web/src/components/site-chrome.tsx
T
Jatin K MalikandGitHub f7147bdb44 fix(web): SEO fixes from Search Console and Lighthouse audit (#1039)
* fix(web): SEO fixes from Search Console and Lighthouse audit

Technical:
- Emit BreadcrumbList JSON-LD from the same trail as the visual breadcrumbs
  on every SeoSubpageShell page.
- absoluteUrl() no longer appends a trailing slash to file paths. The
  Organization and publisher logo URLs pointed at /vocalinux.png/, a 404.
- Fetch the GitHub star count once at build time in next.config.js and
  drop the client-side api.github.com fetch and preconnect (Lighthouse
  "third-party code" on every page). Falls back to the "GitHub" label.
- Fix h1 -> h3 heading skips on 11 pages by promoting the leading card
  headings to h2; new .subpage-card-title keeps the old h3 sizing.
- Header and footer logo get alt="" next to the visible "Vocalinux" text.
- Footer links every guide, distro and feature page, so the pages added in
  #1038 and /vs-nerd-dictation/ (Discovered, not indexed) and /writers/
  (no inbound links) are linked sitewide.

Titles and descriptions:
- Rewrite titles/descriptions for pages with high impressions and low CTR
  in Search Console (home, FAQ, compare, languages, open-source, GPU,
  alternatives, offline, install) to lead with the queries they rank for.
- Distro pages use "<Distro> Speech to Text: Offline Voice Typing Setup"
  as the search title; the on-page h1 is unchanged.
- Remove brand repetition and shorten 75+ character titles.
- Bump sitemap lastmod for the rewritten pages.

* fix(web): keep home install card titles styled

The home page's install cards still use h3; only /install/ moved to h2.
Style both so the home cards keep their size and spacing.
2026-11-01 01:39:01 -07:00

265 lines
7.4 KiB
TypeScript

"use client";
import React, { useEffect, useId, useState } from "react";
import Link from "next/link";
import { Star } from "lucide-react";
import { VocalinuxLogo } from "@/components/optimized-image";
import { GITHUB_REPO_URL, VOCAHQ_URL } from "@/lib/seo";
import {
footerGroups,
homeNav,
mobileExtraNav,
pageNav,
socialLinks,
type NavLink,
} from "@/lib/site-nav";
// Star count is fetched once at build time (next.config.js) so visitors never
// call api.github.com. Empty or missing falls back to the "GitHub" label.
const GITHUB_STARS = Number(process.env.NEXT_PUBLIC_GITHUB_STARS) || null;
function SmartLink({
href,
className,
children,
onClick,
}: {
href: string;
className?: string;
children: React.ReactNode;
onClick?: () => void;
}) {
const external = href.startsWith("http");
if (external) {
return (
<a
href={href}
className={className}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
}
if (href.startsWith("#")) {
return (
<a href={href} className={className} onClick={onClick}>
{children}
</a>
);
}
return (
<Link href={href} className={className} onClick={onClick}>
{children}
</Link>
);
}
export function SiteChrome({
children,
variant = "page",
}: {
children: React.ReactNode;
variant?: "home" | "page";
}) {
const [open, setOpen] = useState(false);
const stars = GITHUB_STARS;
const navId = useId();
const nav = variant === "home" ? homeNav : pageNav;
const ctaHref = variant === "home" ? "#install" : "/#install";
const extras: NavLink[] =
variant === "home"
? [
{ href: "/screenshots/", label: "Screenshots" },
{ href: "/install/", label: "Distro guides" },
...mobileExtraNav,
]
: mobileExtraNav;
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const onResize = () => {
if (window.innerWidth >= 921) setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<div className="min-h-[100dvh] max-w-[100vw] overflow-x-clip">
<a className="skip-link" href="#main">
Skip to content
</a>
<header className="site-header">
<div className="shell header-inner">
<Link href="/" className="brand" aria-label="Vocalinux home">
<VocalinuxLogo
width={28}
height={28}
className="h-7 w-7"
priority
decorative
/>
<span>Vocalinux</span>
</Link>
<nav className="nav-links" aria-label="Primary">
{nav.map((item) => (
<SmartLink key={item.href + item.label} href={item.href}>
{item.label}
</SmartLink>
))}
</nav>
<div className="header-actions">
<a
className="header-stars"
href={GITHUB_REPO_URL}
rel="noopener noreferrer"
aria-label={
stars !== null
? `Vocalinux on GitHub, ${stars.toLocaleString("en-US")} stars`
: "Vocalinux on GitHub"
}
>
<Star
className="h-4 w-4"
strokeWidth={1.75}
fill="currentColor"
aria-hidden="true"
/>
<span>{stars !== null ? stars.toLocaleString("en-US") : "GitHub"}</span>
</a>
<button
className="nav-toggle"
type="button"
aria-expanded={open}
aria-controls={navId}
aria-label="Toggle menu"
onClick={() => setOpen((value) => !value)}
>
<span className="sr-only">
{open ? "Close navigation" : "Open navigation"}
</span>
<span className="nav-toggle-bars" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</span>
</button>
</div>
</div>
<nav
id={navId}
className="mobile-nav shell"
aria-label="Site"
hidden={!open}
>
{[...nav, ...extras].map((item) => (
<SmartLink
key={`m-${item.href}-${item.label}`}
href={item.href}
onClick={() => setOpen(false)}
>
{item.label}
</SmartLink>
))}
<a
className="btn btn-primary"
href={ctaHref}
onClick={() => setOpen(false)}
>
Install Vocalinux
</a>
</nav>
</header>
<main id="main">{children}</main>
<footer className="site-footer">
<div className="shell">
<div className="footer-inner">
<div className="footer-brand">
<Link href="/" className="brand">
<VocalinuxLogo
width={28}
height={28}
className="h-7 w-7"
decorative
/>
<span>Vocalinux</span>
</Link>
<p>
Offline voice typing for Linux. Local engines by default, no
required account.
</p>
<ul className="footer-social">
{socialLinks.map((item) => (
<li key={item.href}>
<a
href={item.href}
{...(item.href.startsWith("http")
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
aria-label={item.label}
title={item.label}
>
<span
className="footer-social-icon"
style={{
maskImage: `url(${item.icon})`,
WebkitMaskImage: `url(${item.icon})`,
}}
/>
</a>
</li>
))}
</ul>
</div>
{footerGroups.map((group) => (
<div className="footer-col" key={group.title}>
<h2>{group.title}</h2>
<ul>
{group.links.map((link) => (
<li key={link.href + link.label}>
<SmartLink href={link.href}>{link.label}</SmartLink>
</li>
))}
</ul>
</div>
))}
</div>
<div className="footer-base">
<span>
AGPL-3.0. Built in public by{" "}
<a
href="https://github.com/jatinkrmalik"
rel="noopener noreferrer"
>
Jatin K Malik
</a>
.
</span>
<span>
Part of{" "}
<a href={VOCAHQ_URL} rel="noopener noreferrer">
VocaHQ
</a>
</span>
</div>
</div>
</footer>
</div>
);
}