* 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.
265 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|